mod.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  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. pub mod flare;
  16. pub mod flare_package;
  17. mod provision;
  18. /// DGPP HTTP port for a recipe (its `[dgpp].http_port`, else 18080).
  19. pub fn http_port(recipe: &Recipe) -> u16 {
  20. recipe
  21. .dgpp
  22. .as_ref()
  23. .and_then(|d| d.http_port)
  24. .unwrap_or(18080)
  25. }
  26. /// DGPP repo checkout path on the nodes (its `[dgpp].dir`, else `~/dgpp`).
  27. fn dgpp_dir(d: &DgppConfig) -> String {
  28. d.dir.clone().unwrap_or_else(|| "~/dgpp".to_string())
  29. }
  30. /// Rewrite a leading `~` to `$HOME` so the remote shell expands it (a `~`
  31. /// inside single quotes would be a literal directory named `~`). Callers wrap
  32. /// the result in double quotes.
  33. fn expand_home(path: &str) -> String {
  34. if path == "~" {
  35. "$HOME".to_string()
  36. } else if let Some(rest) = path.strip_prefix("~/") {
  37. format!("$HOME/{rest}")
  38. } else {
  39. path.to_string()
  40. }
  41. }
  42. /// Build the DGPP cluster JSON from the recipe. `model` and `world_size` come
  43. /// from the recipe + launch node count; `http` from `[dgpp].http_port`; the
  44. /// `engine` block is the recipe's `[dgpp.engine]` map serialized verbatim.
  45. pub fn generate_cluster_json(recipe: &Recipe, world_size: usize) -> Result<String> {
  46. let d = recipe
  47. .dgpp
  48. .as_ref()
  49. .ok_or_else(|| anyhow::anyhow!("recipe has no [dgpp] block"))?;
  50. let mut root = serde_json::Map::new();
  51. root.insert(
  52. "model".into(),
  53. serde_json::Value::String(recipe.model.clone()),
  54. );
  55. root.insert(
  56. "world_size".into(),
  57. serde_json::Value::from(world_size as i64),
  58. );
  59. if let Some(r) = &d.release {
  60. root.insert("release".into(), serde_json::Value::String(r.clone()));
  61. }
  62. if !d.engine.is_empty() {
  63. let mut eng = serde_json::Map::new();
  64. for (k, v) in &d.engine {
  65. eng.insert(k.clone(), v.clone());
  66. }
  67. root.insert("engine".into(), serde_json::Value::Object(eng));
  68. }
  69. let mut http = serde_json::Map::new();
  70. http.insert(
  71. "bind_host".into(),
  72. serde_json::Value::String("0.0.0.0".into()),
  73. );
  74. http.insert(
  75. "port".into(),
  76. serde_json::Value::from(http_port(recipe) as i64),
  77. );
  78. root.insert("http".into(), serde_json::Value::Object(http));
  79. // `paths` always present (DGPP examples carry it); resident_cache empty =
  80. // DGPP default.
  81. let mut paths = serde_json::Map::new();
  82. paths.insert(
  83. "resident_cache".into(),
  84. serde_json::Value::String(d.resident_cache.clone().unwrap_or_default()),
  85. );
  86. root.insert("paths".into(), serde_json::Value::Object(paths));
  87. Ok(serde_json::to_string_pretty(&serde_json::Value::Object(
  88. root,
  89. ))?)
  90. }
  91. /// Build the DGPP site `.env` (allowlisted KEY=value lines DGPP's site_env.py
  92. /// reads): the launch nodes (ssh_ip, rank order), the ssh user, and the ports.
  93. pub fn generate_site_env(ssh_user: &str, cluster: &Cluster, recipe: &Recipe) -> String {
  94. let nodes = cluster
  95. .nodes
  96. .iter()
  97. .map(|n| n.ssh_ip.clone())
  98. .collect::<Vec<_>>()
  99. .join(" ");
  100. let port = http_port(recipe);
  101. format!(
  102. "DGPP_NODES={nodes}\nDGPP_SSH_USER={user}\nDGPP_HTTP_PORT={port}\nDGPP_FABRIC_PORT=29970\nDGPP_JOURNAL_PORT=29971\n",
  103. user = ssh_user,
  104. )
  105. }
  106. /// Write a file on `node` via a single-quoted heredoc (content has no line
  107. /// equal to the delimiter — JSON/env never do). `path` is already `$HOME`-
  108. /// expanded and is wrapped in double quotes so it expands on the remote shell.
  109. async fn write_remote(
  110. config: &Config,
  111. node: &Node,
  112. path: &str,
  113. content: &str,
  114. dry_run: bool,
  115. ) -> Result<()> {
  116. let cmd = format!("cat > \"{path}\" <<'SM121DGPP'\n{content}\nSM121DGPP\n");
  117. crate::ssh::run_ssh(config, node, cmd, dry_run).await?;
  118. Ok(())
  119. }
  120. /// Serve a DGPP recipe: select the cluster, run before_serve hooks, upload the
  121. /// generated cluster JSON + `.env` to rank 0, and launch `dgpp-cluster up`
  122. /// there (head-coordinated — the manager brings up the peers).
  123. pub async fn native_serve(
  124. config: &Config,
  125. recipe_name: &str,
  126. cluster_name: &str,
  127. node_name: Option<&str>,
  128. opts: &ServeOpts,
  129. ) -> Result<Cluster> {
  130. let dry_run = opts.dry_run;
  131. let recipe_obj = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
  132. if recipe_obj.engine == "flare" {
  133. return flare_serve(
  134. config,
  135. &recipe_obj,
  136. recipe_name,
  137. cluster_name,
  138. node_name,
  139. opts,
  140. )
  141. .await;
  142. }
  143. let dgpp = recipe_obj
  144. .dgpp
  145. .clone()
  146. .ok_or_else(|| anyhow::anyhow!("engine=dgpp requires a [dgpp] block"))?;
  147. let (mut cluster, _nccl) = if dry_run {
  148. crate::orchestrate::prepare_cluster_dry_run(config, &recipe_obj, cluster_name, node_name)?
  149. } else {
  150. use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
  151. use crate::orchestrate::{BootTiming, PrepareRequest, prepare_cluster};
  152. let power = opts
  153. .auto_power_on
  154. .then(|| ha_controller_from_config(config))
  155. .flatten();
  156. prepare_cluster(
  157. config,
  158. &recipe_obj,
  159. cluster_name,
  160. node_name,
  161. &PrepareRequest {
  162. prober: &SshProber,
  163. power: power.as_ref(),
  164. pinger: &SshNodePinger,
  165. auto_power_on: opts.auto_power_on,
  166. boot_timing: BootTiming::default(),
  167. force_occupied: false,
  168. },
  169. )
  170. .await?
  171. };
  172. // Trim to the launch set (world size = required_nodes).
  173. if let Some(n) = recipe_obj.required_nodes
  174. && (n as usize) < cluster.nodes.len()
  175. {
  176. cluster.nodes.truncate(n as usize);
  177. }
  178. let head = cluster
  179. .nodes
  180. .iter()
  181. .find(|n| n.rank == 0)
  182. .cloned()
  183. .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
  184. crate::before_serve::run(
  185. config,
  186. &cluster,
  187. &recipe_obj,
  188. recipe_name,
  189. opts.skip_before_serve,
  190. dry_run,
  191. )
  192. .await?;
  193. // Provision the DGPP checkout at the pinned rev on every launch node (the
  194. // native analog of building the docker image on demand): clone-at-pin +
  195. // release.sh + install, with a rev-marker skip. No-op when the recipe
  196. // declares no [dgpp].repo/rev (assume pre-installed). Nodes in parallel.
  197. let dir = expand_home(&dgpp_dir(&dgpp));
  198. let cfg_name = format!("cluster_sm121_{recipe_name}.json");
  199. let cfg_path = format!("{dir}/{cfg_name}");
  200. let env_path = format!("{dir}/.env");
  201. let json = generate_cluster_json(&recipe_obj, cluster.nodes.len())?;
  202. let env = generate_site_env(&config.ssh_user, &cluster, &recipe_obj);
  203. // Stage the generated cluster JSON on EVERY launch node — DGPP's
  204. // `dgpp-cluster install` needs a deployment config, and each node runs a
  205. // rank — plus the site `.env` on every node too (install validates
  206. // DGPP_NODES per node; the rank-0 manager reads it for `up`).
  207. for n in &cluster.nodes {
  208. crate::ssh::run_ssh(
  209. config,
  210. n,
  211. format!("mkdir -p \"{dir}\" && mkdir -p \"{dir}/log\""),
  212. dry_run,
  213. )
  214. .await?;
  215. write_remote(config, n, &cfg_path, &json, dry_run).await?;
  216. write_remote(config, n, &env_path, &env, dry_run).await?;
  217. }
  218. // Provision (clone-at-pin + release.sh + install --config <staged json>) on
  219. // RANK 0 ONLY — `dgpp-cluster install` is head-coordinated: it installs the
  220. // release on rank 0 AND SSHes to the peers (from DGPP_NODES) to install
  221. // there too, like the docker build-once + sync model. Running it on every
  222. // node would double-install the peers ("release already exists").
  223. let prov = provision::provision_node(config, &head, recipe_name, &dgpp, &cfg_path, dry_run);
  224. if let Err(e) = prov.await {
  225. anyhow::bail!("DGPP provisioning failed on head '{}': {e}", head.name);
  226. }
  227. // Launch on rank 0 only; the DGPP manager SSHes to the peers. Detached so
  228. // it survives the SSH session (nohup + log file).
  229. let up = format!(
  230. "cd \"{dir}\" && set -a && . ./.env && set +a && nohup scripts/dgpp-cluster up --config '{cfg_name}' > 'log/sm121-{recipe_name}.log' 2>&1 &"
  231. );
  232. info!("🚀 {} (dgpp, head {})", recipe_obj.name, head.name);
  233. crate::ssh::run_ssh(config, &head, up, dry_run).await?;
  234. if !dry_run {
  235. crate::runlock::mark_start(recipe_name, &config.recipe_sources, Some(&cluster.name))?;
  236. if !opts.no_wait_ready
  237. && let Err(e) = crate::readiness::wait_engine(
  238. config,
  239. &cluster,
  240. http_port(&recipe_obj),
  241. 1800,
  242. recipe_name,
  243. )
  244. .await
  245. {
  246. let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
  247. return Err(e);
  248. }
  249. }
  250. Ok(cluster)
  251. }
  252. /// Stop a DGPP run: `dgpp-cluster down` on rank 0 (the manager stops the
  253. /// peers), then the caller closes the ledger.
  254. /// FLARE path: prepare the cluster (same SSH/HA prepare as dgpp), then launch
  255. /// the signed package through `flare orbit`.
  256. async fn flare_serve(
  257. config: &Config,
  258. recipe: &Recipe,
  259. recipe_name: &str,
  260. cluster_name: &str,
  261. node_name: Option<&str>,
  262. opts: &ServeOpts,
  263. ) -> Result<Cluster> {
  264. let (cluster, _nccl) = if opts.dry_run {
  265. crate::orchestrate::prepare_cluster_dry_run(config, recipe, cluster_name, node_name)?
  266. } else {
  267. use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
  268. use crate::orchestrate::{PrepareRequest, prepare_cluster};
  269. let power = opts
  270. .auto_power_on
  271. .then(|| ha_controller_from_config(config))
  272. .flatten();
  273. prepare_cluster(
  274. config,
  275. recipe,
  276. cluster_name,
  277. node_name,
  278. &PrepareRequest {
  279. prober: &SshProber,
  280. power: power.as_ref(),
  281. pinger: &SshNodePinger,
  282. auto_power_on: opts.auto_power_on,
  283. boot_timing: crate::orchestrate::BootTiming::default(),
  284. force_occupied: false,
  285. },
  286. )
  287. .await?
  288. };
  289. if recipe.flare.is_none() {
  290. anyhow::bail!("engine=flare requires a [flare] block");
  291. }
  292. // Cluster identification: the orbit name must come from this cluster's own
  293. // `[flare.clusters.<name>]` entry. Falling back to the flat `sparks`
  294. // default silently launches onto another machine's orbit (our clusters
  295. // share nodes, and `orbit run` creates the group wherever it is told).
  296. let flat = flare::orbit_cluster_for(config, &cluster.name);
  297. if let Some(orbit) = flare::orbit_cluster_paired(config, &cluster.name) {
  298. info!("FLARE: cluster '{}' → orbit '{}'", cluster.name, orbit);
  299. } else {
  300. anyhow::bail!(
  301. "FLARE cluster '{}' is not paired with an orbit cluster — add a \
  302. [flare.clusters.\"{}\"] block (orbit_cluster = \"…\") to the private \
  303. config. Without it the launch would silently target the flat \
  304. default '{}'.",
  305. cluster.name,
  306. cluster.name,
  307. flat
  308. );
  309. }
  310. // before_serve hooks (the global GPU clock lock among them): a Spark that
  311. // runs unclocked drifts and stalls a collective, so native launches get
  312. // the same prepare step docker/tmux lanes do.
  313. crate::before_serve::run(
  314. config,
  315. &cluster,
  316. recipe,
  317. cluster_name,
  318. opts.skip_before_serve,
  319. opts.dry_run,
  320. )
  321. .await?;
  322. flare::serve(config, recipe, &cluster, opts.dry_run).await?;
  323. // The run ledger is the single-launcher serialization point: a FLARE group
  324. // is as real as a container, so it must be recorded (otherwise a second
  325. // launcher sees "no open run" and starts a second lane on the same nodes).
  326. if !opts.dry_run {
  327. crate::runlock::mark_start(recipe_name, &config.recipe_sources, Some(&cluster.name))?;
  328. }
  329. Ok(cluster)
  330. }
  331. pub async fn native_stop(
  332. config: &Config,
  333. recipe: &Recipe,
  334. cluster: &Cluster,
  335. dry_run: bool,
  336. ) -> Result<()> {
  337. if recipe.engine == "flare" {
  338. return flare::stop(config, recipe, cluster, dry_run).await;
  339. }
  340. let Some(d) = recipe.dgpp.as_ref() else {
  341. anyhow::bail!("engine=dgpp requires a [dgpp] block to stop");
  342. };
  343. let dir = expand_home(&dgpp_dir(d));
  344. let head = cluster
  345. .nodes
  346. .iter()
  347. .find(|n| n.rank == 0)
  348. .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
  349. let down = format!("cd \"{dir}\" && scripts/dgpp-cluster down");
  350. crate::ssh::run_ssh(config, head, down, dry_run).await?;
  351. Ok(())
  352. }
  353. #[cfg(test)]
  354. mod tests {
  355. use super::*;
  356. use crate::config::{DgppConfig, Node};
  357. use serde_json::json;
  358. fn dgpp_recipe() -> Recipe {
  359. let mut r = crate::engine::tests::base_recipe();
  360. r.engine = "dgpp".into();
  361. r.name = "dgpp-qwen".into();
  362. r.model = "unsloth/Qwen3.8-27B-NVFP4".into();
  363. r.port = 18080;
  364. r.required_nodes = Some(1);
  365. r.deploy = crate::config::DeployTarget::Native;
  366. r.dgpp = Some(DgppConfig {
  367. http_port: Some(18080),
  368. engine: [
  369. ("kv_capacity".to_string(), json!(262144)),
  370. ("mtp".to_string(), json!(true)),
  371. (
  372. "dflash_model".to_string(),
  373. json!("z-lab/Qwen3.8-27B-DFlash2"),
  374. ),
  375. ]
  376. .into_iter()
  377. .collect(),
  378. ..Default::default()
  379. });
  380. r
  381. }
  382. #[test]
  383. fn cluster_json_carries_model_world_size_engine_and_http() {
  384. let r = dgpp_recipe();
  385. let json: serde_json::Value =
  386. serde_json::from_str(&generate_cluster_json(&r, 1).unwrap()).unwrap();
  387. assert_eq!(json["model"], "unsloth/Qwen3.8-27B-NVFP4");
  388. assert_eq!(json["world_size"], 1);
  389. assert_eq!(json["engine"]["kv_capacity"], 262144);
  390. assert_eq!(json["engine"]["mtp"], true);
  391. assert_eq!(json["engine"]["dflash_model"], "z-lab/Qwen3.8-27B-DFlash2");
  392. assert_eq!(json["http"]["port"], 18080);
  393. assert_eq!(json["http"]["bind_host"], "0.0.0.0");
  394. assert_eq!(json["paths"]["resident_cache"], "");
  395. }
  396. #[test]
  397. fn expand_home_rewrites_leading_tilde() {
  398. assert_eq!(expand_home("~/dgpp"), "$HOME/dgpp");
  399. assert_eq!(expand_home("~"), "$HOME");
  400. assert_eq!(expand_home("/srv/dgpp"), "/srv/dgpp");
  401. }
  402. #[test]
  403. fn site_env_lists_nodes_ssh_user_and_ports() {
  404. let r = dgpp_recipe();
  405. let cluster = Cluster {
  406. name: "dgx1".into(),
  407. master_ib_ip: None,
  408. nodes: vec![
  409. Node {
  410. name: "dgx1".into(),
  411. ssh_ip: "192.168.88.10".into(),
  412. ib_ip: None,
  413. rank: 0,
  414. host_key: None,
  415. entity: None,
  416. },
  417. Node {
  418. name: "dgx2".into(),
  419. ssh_ip: "192.168.88.14".into(),
  420. ib_ip: None,
  421. rank: 1,
  422. host_key: None,
  423. entity: None,
  424. },
  425. ],
  426. extra_entities: vec![],
  427. };
  428. let env = generate_site_env("kilork", &cluster, &r);
  429. assert!(env.contains("DGPP_NODES=192.168.88.10 192.168.88.14"));
  430. assert!(env.contains("DGPP_SSH_USER=kilork"));
  431. assert!(env.contains("DGPP_HTTP_PORT=18080"));
  432. }
  433. }