publish_render.rs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. //! Bundle rendering for `sm121 publish`: run script, install script, README,
  2. //! HF revision lookup, and the `gh gist` flow.
  3. use std::collections::BTreeMap;
  4. use std::path::Path;
  5. use std::process::Command;
  6. use anyhow::{Context, Result, bail};
  7. use tracing::info;
  8. use crate::config::{Cluster, NcclConfig, Recipe};
  9. use crate::ssh::shq;
  10. pub(crate) fn render_run_script(
  11. recipe: &Recipe,
  12. cluster: Option<&Cluster>,
  13. nccl: Option<&NcclConfig>,
  14. hf_cache: &str,
  15. init_port: u16,
  16. revision: Option<&str>,
  17. ) -> Result<String> {
  18. let nodes = cluster.map_or_else(
  19. || recipe.required_nodes.unwrap_or(2) as usize,
  20. |c| c.nodes.len(),
  21. );
  22. let solo = recipe.solo == Some(true) || nodes <= 1;
  23. let master = cluster
  24. .and_then(|c| c.master_ib_ip.clone())
  25. .or_else(|| cluster.and_then(|c| c.nodes.iter().find(|n| n.rank == 0)?.ib_ip.clone()))
  26. .unwrap_or_else(|| "<rank0-IB-ip>".into());
  27. let mut s = String::new();
  28. s.push_str(&format!(
  29. "#!/usr/bin/env bash\n\
  30. # {} — generated by `sm121 publish`. Run on rank 0; on each worker set\n\
  31. # NODE_RANK=<rank> and HEADLESS=\"--headless\" (drop --host/--port).\n\
  32. set -euo pipefail\n\n\
  33. MODEL={}\n\
  34. IMAGE={}\n",
  35. recipe.name,
  36. shq(&recipe.model),
  37. shq(&recipe.docker_image),
  38. ));
  39. if let Some(r) = revision {
  40. s.push_str(&format!(
  41. "MODEL_REVISION={} # pin: download + serve THIS revision (repo branches move)\n",
  42. shq(r)
  43. ));
  44. } else if recipe.model.contains('/') {
  45. s.push_str(
  46. "# MODEL_REVISION= # consider pinning the HF revision (the repo branch moves)\n",
  47. );
  48. }
  49. if !solo {
  50. s.push_str(&format!(
  51. "MASTER_ADDR={} # rank-0 IB ip\nNODE_RANK=0\nHEADLESS=\"\"\n",
  52. shq(&master)
  53. ));
  54. }
  55. s.push_str("\n# --- fabric env (adjust per cluster) ------------------------------------\n");
  56. match nccl {
  57. Some(n) => {
  58. s.push_str(&format!(
  59. "FABRIC_ENV=(\n -e NCCL_IB_HCA={}\n -e NCCL_SOCKET_IFNAME={}\n -e GLOO_SOCKET_IFNAME={}\n -e NCCL_IB_DISABLE={}\n",
  60. shq(&n.ib_hca), shq(&n.socket_ifname),
  61. shq(n.gloo_socket_ifname.as_deref().unwrap_or(&n.socket_ifname)), n.ib_disable));
  62. match n.ib_gid_index {
  63. Some(g) => s.push_str(&format!(" -e NCCL_IB_GID_INDEX={g}\n)\n")),
  64. None => s.push_str(" # NCCL_IB_GID_INDEX renumbers across reboots on DGX Spark —\n # detect the RoCE-v2 IPv4 GID per node, don't copy a stale number:\n # for i in $(seq 0 15); do grep -l RoCE* /sys/class/infiniband/$HCA/ports/1/gid_attrs/types/$i 2>/dev/null && echo $i; done\n)\n"),
  65. }
  66. }
  67. None => s.push_str("FABRIC_ENV=(\n # -e NCCL_IB_HCA=... -e NCCL_SOCKET_IFNAME=... -e NCCL_IB_GID_INDEX=...\n)\n"),
  68. }
  69. s.push_str(&format!(
  70. "\nexec docker run -d --name pub-{} --gpus all --ipc=host --network host \\\n --shm-size {} \\\n -v {}:/root/.cache/huggingface \\\n \"${{FABRIC_ENV[@]}}\" \\\n",
  71. recipe.model.replace('/', "-"),
  72. if recipe.shm_size.is_empty() { "32g".into() } else { recipe.shm_size.clone() },
  73. hf_cache, // unquoted: `~` must expand in bash
  74. ));
  75. let env = recipe.resolved_env()?;
  76. for (k, v) in env.iter().collect::<BTreeMap<_, _>>() {
  77. s.push_str(&format!(" -e {} \\\n", shq(&format!("{k}={v}"))));
  78. }
  79. s.push_str(" \"$IMAGE\" \\\n");
  80. s.push_str(&format!(
  81. " --model \"$MODEL\"{}",
  82. if revision.is_some() {
  83. " --revision \"$MODEL_REVISION\""
  84. } else {
  85. ""
  86. }
  87. ));
  88. if !solo {
  89. if recipe.engine == "sglang" {
  90. s.push_str(&format!(
  91. " \\\n --tp {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --dist-init-addr \"$MASTER_ADDR:{init_port}\"",
  92. recipe.tensor_parallel_size
  93. ));
  94. } else {
  95. s.push_str(&format!(
  96. " \\\n --tensor-parallel-size {} --nnodes {nodes} --node-rank \"$NODE_RANK\" --master-addr \"$MASTER_ADDR\" --master-port {init_port}",
  97. recipe.tensor_parallel_size
  98. ));
  99. if let Some(pp) = recipe.pipeline_parallel_size.filter(|p| *p > 1) {
  100. s.push_str(&format!(" --pipeline-parallel-size {pp}"));
  101. }
  102. }
  103. }
  104. for a in recipe.args.to_vec() {
  105. s.push_str(&format!(" \\\n {}", shq(&a)));
  106. }
  107. if !solo {
  108. s.push_str(" \\\n ${HEADLESS:+$HEADLESS}");
  109. }
  110. s.push_str(&format!(" \\\n --host 0.0.0.0 --port {}\n", recipe.port));
  111. Ok(s)
  112. }
  113. pub(crate) fn render_install_sh(
  114. recipe_name: &str,
  115. image: &str,
  116. versions: &BTreeMap<String, String>,
  117. gist_id: Option<&str>,
  118. ) -> String {
  119. let args = versions
  120. .iter()
  121. .map(|(k, v)| format!(" --build-arg {}={} \\\n", shq(k), shq(v)))
  122. .collect::<String>();
  123. let gid = gist_id
  124. .map(shq)
  125. .unwrap_or_else(|| "' '".to_string());
  126. let image_q = shq(image);
  127. format!(
  128. "#!/usr/bin/env bash\n\
  129. # Build {image}. Run from this folder, or set GIST_ID and run from\n\
  130. # anywhere to clone the published gist into /tmp first.\n\
  131. # Usage: curl -fsSL <raw-url>/install.sh | bash (after publishing as a gist)\n\
  132. set -euo pipefail\n\n\
  133. GIST_ID={gid} # filled in by `sm121 publish --gist`\n\
  134. DIR=/tmp/{recipe_name}-build\n\
  135. IMAGE={image_q}\n\n\
  136. HERE=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\
  137. if [ -f \"$HERE/Dockerfile\" ]; then\n SRC=\"$HERE\"\n\
  138. else\n rm -rf \"$DIR\"; git clone -q \"https://gist.github.com/${{GIST_ID}}.git\" \"$DIR\"; SRC=\"$DIR\"\n\
  139. fi\n\n\
  140. docker build -t \"$IMAGE\" \\\n{args}\"$SRC\"\n\
  141. echo \"built: $IMAGE — see run.sh\"\n",
  142. )
  143. }
  144. pub(crate) fn render_readme(
  145. recipe_name: &str,
  146. recipe: &Recipe,
  147. versions: &BTreeMap<String, String>,
  148. revision: Option<&str>,
  149. has_image: bool,
  150. profile_used: bool,
  151. ) -> String {
  152. let mut s = format!("# {} — reproducible recipe card\n\n", recipe.name);
  153. s.push_str(&format!(
  154. "Generated by `sm121 publish` from the [sm121-launcher](https://git.kilork.org/sm121/sm121-launcher) \
  155. recipe `{recipe_name}` (engine: {}, model: `{}`).\n\n",
  156. recipe.engine, recipe.model
  157. ));
  158. s.push_str(
  159. "**`recipe.toml` is the source of truth — its header comments document every \
  160. non-obvious flag and the measured boot markers. Read it first.**\n\n",
  161. );
  162. s.push_str("## Files\n\n| file | what |\n|---|---|\n| `recipe.toml` | the launcher recipe (rationale in comments) |\n");
  163. if has_image {
  164. s.push_str(
  165. "| `Dockerfile`, patches, `versions.env` | image overlay (flat — build here) |\n",
  166. );
  167. }
  168. s.push_str("| `install.sh` | one-shot build (gist-aware) |\n| `run.sh` | docker run, rank 0 (worker deltas in comments) |\n\n");
  169. if has_image {
  170. s.push_str("## Build\n\n```bash\nbash install.sh\n```\n\n");
  171. s.push_str("## Pinned coordinates\n\n| thing | ref |\n|---|---|\n");
  172. if profile_used {
  173. s.push_str(&format!(
  174. "| image pins | `versions.env` with {} merged in (precedence: image base < profile < recipe [build.versions]) |\n",
  175. recipe
  176. .build_profile
  177. .as_deref()
  178. .map(|p| format!("profile `{p}`"))
  179. .unwrap_or_else(|| "`[build.versions]` overrides".into())
  180. ));
  181. }
  182. if let Some(b) = versions.get("BASE_IMAGE") {
  183. let note = if b.contains('@') {
  184. "digest-pinned"
  185. } else {
  186. "⚠ mutable tag — re-pin if reproducibility matters"
  187. };
  188. s.push_str(&format!("| base image | `{b}` ({note}) |\n"));
  189. }
  190. match revision {
  191. Some(r) => s.push_str(&format!("| model | `{}` @ `{r}` |\n", recipe.model)),
  192. None if recipe.model.contains('/') => s.push_str(&format!(
  193. "| model | `{}` @ main — ⚠ unpinned branch |\n",
  194. recipe.model
  195. )),
  196. _ => {}
  197. }
  198. s.push('\n');
  199. }
  200. s.push_str(
  201. "## Run\n\nCache the model on every node (see `recipe.toml` PREREQUISITES), edit \
  202. `MASTER_ADDR`/fabric env in `run.sh`, run on rank 0, then on each worker with \
  203. `NODE_RANK`/`HEADLESS` set. `recipe.toml` `[[extra_mounts]]` and built-in \
  204. `before_serve` host scripts are launcher-side — translate manually if needed.\n",
  205. );
  206. s
  207. }
  208. pub(crate) async fn hf_revision(model: &str) -> Option<String> {
  209. if !model.contains('/') || model.starts_with('.') || model.starts_with('/') {
  210. return None;
  211. }
  212. let url = format!("https://huggingface.co/api/models/{model}");
  213. let resp = reqwest::Client::builder()
  214. .timeout(std::time::Duration::from_secs(8))
  215. .build()
  216. .ok()?
  217. .get(url)
  218. .send()
  219. .await
  220. .ok()?
  221. .json::<serde_json::Value>()
  222. .await
  223. .ok()?;
  224. resp.get("sha")?.as_str().map(str::to_string)
  225. }
  226. pub(crate) fn publish_gist(bundle: &Path, desc: &str) -> Result<String> {
  227. let mut cmd = Command::new("gh");
  228. cmd.arg("gist")
  229. .arg("create")
  230. .arg("--public")
  231. .arg("--desc")
  232. .arg(desc);
  233. let mut files = Vec::new();
  234. for e in std::fs::read_dir(bundle)? {
  235. let p = e?.path();
  236. if p.is_file() {
  237. files.push(p);
  238. }
  239. }
  240. files.sort();
  241. cmd.args(&files);
  242. let out = cmd
  243. .output()
  244. .context("`gh` not found — install GitHub CLI or copy the bundle manually")?;
  245. if !out.status.success() {
  246. bail!(
  247. "gh gist create failed: {}",
  248. String::from_utf8_lossy(&out.stderr)
  249. );
  250. }
  251. let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
  252. info!("🌐 gist: {url}");
  253. // gh prints the full html url; the gist *id* is the last path segment.
  254. Ok(url.rsplit('/').next().unwrap_or(&url).to_string())
  255. }