| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- //! Native (head-coordinated) deploy: an engine's own cluster manager runs on
- //! rank 0 and brings up the peers itself. Currently only DGPP
- //! (`HawkBearPig/dgpp`, `engine = "dgpp"`, `deploy = "native"`).
- //!
- //! The launcher generates DGPP's cluster JSON (model + world size + engine
- //! knobs + http port) and a sibling `.env` (node list + ssh user + ports) from
- //! the recipe + selected cluster, uploads both to rank 0's DGPP checkout, and
- //! runs `scripts/dgpp-cluster up --config <path>` there. Stop runs
- //! `scripts/dgpp-cluster down`. Readiness is the uniform `/v1/models` probe on
- //! the DGPP HTTP port (default 18080).
- use anyhow::Result;
- use tracing::info;
- use crate::config::{Cluster, Config, DgppConfig, Node, Recipe};
- use crate::docker::ServeOpts;
- /// DGPP HTTP port for a recipe (its `[dgpp].http_port`, else 18080).
- pub fn http_port(recipe: &Recipe) -> u16 {
- recipe
- .dgpp
- .as_ref()
- .and_then(|d| d.http_port)
- .unwrap_or(18080)
- }
- /// DGPP repo checkout path on the nodes (its `[dgpp].dir`, else `~/dgpp`).
- fn dgpp_dir(d: &DgppConfig) -> String {
- d.dir.clone().unwrap_or_else(|| "~/dgpp".to_string())
- }
- /// Rewrite a leading `~` to `$HOME` so the remote shell expands it (a `~`
- /// inside single quotes would be a literal directory named `~`). Callers wrap
- /// the result in double quotes.
- fn expand_home(path: &str) -> String {
- if path == "~" {
- "$HOME".to_string()
- } else if let Some(rest) = path.strip_prefix("~/") {
- format!("$HOME/{rest}")
- } else {
- path.to_string()
- }
- }
- /// Build the DGPP cluster JSON from the recipe. `model` and `world_size` come
- /// from the recipe + launch node count; `http` from `[dgpp].http_port`; the
- /// `engine` block is the recipe's `[dgpp.engine]` map serialized verbatim.
- pub fn generate_cluster_json(recipe: &Recipe, world_size: usize) -> Result<String> {
- let d = recipe
- .dgpp
- .as_ref()
- .ok_or_else(|| anyhow::anyhow!("recipe has no [dgpp] block"))?;
- let mut root = serde_json::Map::new();
- root.insert(
- "model".into(),
- serde_json::Value::String(recipe.model.clone()),
- );
- root.insert(
- "world_size".into(),
- serde_json::Value::from(world_size as i64),
- );
- if let Some(r) = &d.release {
- root.insert("release".into(), serde_json::Value::String(r.clone()));
- }
- if !d.engine.is_empty() {
- let mut eng = serde_json::Map::new();
- for (k, v) in &d.engine {
- eng.insert(k.clone(), v.clone());
- }
- root.insert("engine".into(), serde_json::Value::Object(eng));
- }
- let mut http = serde_json::Map::new();
- http.insert(
- "bind_host".into(),
- serde_json::Value::String("0.0.0.0".into()),
- );
- http.insert(
- "port".into(),
- serde_json::Value::from(http_port(recipe) as i64),
- );
- root.insert("http".into(), serde_json::Value::Object(http));
- Ok(serde_json::to_string_pretty(&serde_json::Value::Object(
- root,
- ))?)
- }
- /// Build the DGPP site `.env` (allowlisted KEY=value lines DGPP's site_env.py
- /// reads): the launch nodes (ssh_ip, rank order), the ssh user, and the ports.
- pub fn generate_site_env(ssh_user: &str, cluster: &Cluster, recipe: &Recipe) -> String {
- let nodes = cluster
- .nodes
- .iter()
- .map(|n| n.ssh_ip.clone())
- .collect::<Vec<_>>()
- .join(",");
- let port = http_port(recipe);
- format!(
- "DGPP_NODES={nodes}\nDGPP_SSH_USER={user}\nDGPP_HTTP_PORT={port}\nDGPP_FABRIC_PORT=29970\nDGPP_JOURNAL_PORT=29971\n",
- user = ssh_user,
- )
- }
- /// Write a file on `node` via a single-quoted heredoc (content has no line
- /// equal to the delimiter — JSON/env never do). `path` is already `$HOME`-
- /// expanded and is wrapped in double quotes so it expands on the remote shell.
- async fn write_remote(
- config: &Config,
- node: &Node,
- path: &str,
- content: &str,
- dry_run: bool,
- ) -> Result<()> {
- let cmd = format!("cat > \"{path}\" <<'SM121DGPP'\n{content}\nSM121DGPP\n");
- crate::ssh::run_ssh(config, node, cmd, dry_run).await?;
- Ok(())
- }
- /// Serve a DGPP recipe: select the cluster, run before_serve hooks, upload the
- /// generated cluster JSON + `.env` to rank 0, and launch `dgpp-cluster up`
- /// there (head-coordinated — the manager brings up the peers).
- pub async fn native_serve(
- config: &Config,
- recipe_name: &str,
- cluster_name: &str,
- node_name: Option<&str>,
- opts: &ServeOpts,
- ) -> Result<Cluster> {
- let dry_run = opts.dry_run;
- let recipe_obj = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
- let dgpp = recipe_obj
- .dgpp
- .clone()
- .ok_or_else(|| anyhow::anyhow!("engine=dgpp requires a [dgpp] block"))?;
- let (mut cluster, _nccl) = if dry_run {
- crate::orchestrate::prepare_cluster_dry_run(config, &recipe_obj, cluster_name, node_name)?
- } else {
- use crate::ends::{SshNodePinger, SshProber, ha_controller_from_config};
- use crate::orchestrate::{BootTiming, PrepareRequest, prepare_cluster};
- let power = opts
- .auto_power_on
- .then(|| ha_controller_from_config(config))
- .flatten();
- prepare_cluster(
- config,
- &recipe_obj,
- cluster_name,
- node_name,
- &PrepareRequest {
- prober: &SshProber,
- power: power.as_ref(),
- pinger: &SshNodePinger,
- auto_power_on: opts.auto_power_on,
- boot_timing: BootTiming::default(),
- force_occupied: false,
- },
- )
- .await?
- };
- // Trim to the launch set (world size = required_nodes).
- if let Some(n) = recipe_obj.required_nodes
- && (n as usize) < cluster.nodes.len()
- {
- cluster.nodes.truncate(n as usize);
- }
- let head = cluster
- .nodes
- .iter()
- .find(|n| n.rank == 0)
- .cloned()
- .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
- crate::before_serve::run(
- config,
- &cluster,
- &recipe_obj,
- opts.skip_before_serve,
- dry_run,
- )
- .await?;
- let dir = expand_home(&dgpp_dir(&dgpp));
- let cfg_name = format!("cluster_sm121_{recipe_name}.json");
- let cfg_path = format!("{dir}/{cfg_name}");
- let env_path = format!("{dir}/.env");
- let json = generate_cluster_json(&recipe_obj, cluster.nodes.len())?;
- let env = generate_site_env(&config.ssh_user, &cluster, &recipe_obj);
- crate::ssh::run_ssh(
- config,
- &head,
- format!("mkdir -p \"{dir}\" && mkdir -p \"{dir}/log\""),
- dry_run,
- )
- .await?;
- write_remote(config, &head, &cfg_path, &json, dry_run).await?;
- write_remote(config, &head, &env_path, &env, dry_run).await?;
- // Launch on rank 0 only; the DGPP manager SSHes to the peers. Detached so
- // it survives the SSH session (nohup + log file).
- let up = format!(
- "cd \"{dir}\" && set -a && . ./.env && set +a && nohup scripts/dgpp-cluster up --config '{cfg_name}' > 'log/sm121-{recipe_name}.log' 2>&1 &"
- );
- info!("🚀 {} (dgpp, head {})", recipe_obj.name, head.name);
- crate::ssh::run_ssh(config, &head, up, dry_run).await?;
- if !dry_run {
- crate::runlock::mark_start(recipe_name, &config.recipe_sources, Some(&cluster.name))?;
- if !opts.no_wait_ready
- && let Err(e) = crate::readiness::wait_engine(
- config,
- &cluster,
- http_port(&recipe_obj),
- 1800,
- recipe_name,
- )
- .await
- {
- let _ = crate::runlock::mark_stop(recipe_name, &config.recipe_sources);
- return Err(e);
- }
- }
- Ok(cluster)
- }
- /// Stop a DGPP run: `dgpp-cluster down` on rank 0 (the manager stops the
- /// peers), then the caller closes the ledger.
- pub async fn native_stop(
- config: &Config,
- recipe: &Recipe,
- cluster: &Cluster,
- dry_run: bool,
- ) -> Result<()> {
- let Some(d) = recipe.dgpp.as_ref() else {
- anyhow::bail!("engine=dgpp requires a [dgpp] block to stop");
- };
- let dir = expand_home(&dgpp_dir(d));
- let head = cluster
- .nodes
- .iter()
- .find(|n| n.rank == 0)
- .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))?;
- let down = format!("cd \"{dir}\" && scripts/dgpp-cluster down");
- crate::ssh::run_ssh(config, head, down, dry_run).await?;
- Ok(())
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- use crate::config::{DgppConfig, Node};
- use serde_json::json;
- fn dgpp_recipe() -> Recipe {
- let mut r = crate::engine::tests::base_recipe();
- r.engine = "dgpp".into();
- r.name = "dgpp-qwen".into();
- r.model = "unsloth/Qwen3.8-27B-NVFP4".into();
- r.port = 18080;
- r.required_nodes = Some(1);
- r.deploy = crate::config::DeployTarget::Native;
- r.dgpp = Some(DgppConfig {
- http_port: Some(18080),
- engine: [
- ("kv_capacity".to_string(), json!(262144)),
- ("mtp".to_string(), json!(true)),
- (
- "dflash_model".to_string(),
- json!("z-lab/Qwen3.8-27B-DFlash2"),
- ),
- ]
- .into_iter()
- .collect(),
- ..Default::default()
- });
- r
- }
- #[test]
- fn cluster_json_carries_model_world_size_engine_and_http() {
- let r = dgpp_recipe();
- let json: serde_json::Value =
- serde_json::from_str(&generate_cluster_json(&r, 1).unwrap()).unwrap();
- assert_eq!(json["model"], "unsloth/Qwen3.8-27B-NVFP4");
- assert_eq!(json["world_size"], 1);
- assert_eq!(json["engine"]["kv_capacity"], 262144);
- assert_eq!(json["engine"]["mtp"], true);
- assert_eq!(json["engine"]["dflash_model"], "z-lab/Qwen3.8-27B-DFlash2");
- assert_eq!(json["http"]["port"], 18080);
- assert_eq!(json["http"]["bind_host"], "0.0.0.0");
- }
- #[test]
- fn expand_home_rewrites_leading_tilde() {
- assert_eq!(expand_home("~/dgpp"), "$HOME/dgpp");
- assert_eq!(expand_home("~"), "$HOME");
- assert_eq!(expand_home("/srv/dgpp"), "/srv/dgpp");
- }
- #[test]
- fn site_env_lists_nodes_ssh_user_and_ports() {
- let r = dgpp_recipe();
- let cluster = Cluster {
- name: "dgx1".into(),
- master_ib_ip: None,
- nodes: vec![
- Node {
- name: "dgx1".into(),
- ssh_ip: "192.168.88.10".into(),
- ib_ip: None,
- rank: 0,
- host_key: None,
- entity: None,
- },
- Node {
- name: "dgx2".into(),
- ssh_ip: "192.168.88.14".into(),
- ib_ip: None,
- rank: 1,
- host_key: None,
- entity: None,
- },
- ],
- extra_entities: vec![],
- };
- let env = generate_site_env("kilork", &cluster, &r);
- assert!(env.contains("DGPP_NODES=192.168.88.10,192.168.88.14"));
- assert!(env.contains("DGPP_SSH_USER=kilork"));
- assert!(env.contains("DGPP_HTTP_PORT=18080"));
- }
- }
|