flare.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. //! FLARE deploy (`engine = "flare"`, `deploy = "native"`).
  2. //!
  3. //! FLARE (`local-inference-lab/flare`) serves a *signed package* instead of a
  4. //! raw HF checkpoint: `flare download <repo> --revision <rev>` fetches it, and
  5. //! `flare orbit run "<controller>:<path>" --cluster <orbit cluster> …` brings
  6. //! up one rank per paired agent and serves the API on the controller. The
  7. //! launcher therefore does what a human would: preflight the agents, download
  8. //! the package once on rank 0 (stamped, so a repeat serve costs nothing), run
  9. //! orbit detached, then probe `/health` + `/v1/models`.
  10. //!
  11. //! Recipe block:
  12. //! ```toml
  13. //! engine = "flare"
  14. //! deploy = "native"
  15. //! [flare]
  16. //! package = "local-inference-lab/DeepSeek-V4.1-Flash-1017-FLARE"
  17. //! revision = "gb10"
  18. //! name = "deepseek1017"
  19. //! http_port = 8000
  20. //! serve_flags = ["--max-concurrency", "16", "--mtp-draft-tokens", "5"]
  21. //! ```
  22. //! Credentials (`--dangerously-approve-key`, publisher) live in the private
  23. //! config's `[flare]` block, never in the recipe repo.
  24. use anyhow::{Result, bail};
  25. use tracing::info;
  26. use crate::config::{Config, Node, Recipe};
  27. use crate::docker::{ServeOpts, shq};
  28. /// FLARE API port on the controller (recipe `[flare].http_port`, else the
  29. /// cluster default 30000 — the same port every other lane serves on).
  30. pub fn http_port(recipe: &Recipe) -> u16 {
  31. recipe
  32. .flare
  33. .as_ref()
  34. .and_then(|f| f.http_port)
  35. .unwrap_or(30000)
  36. }
  37. /// Orbit cluster name for one of OUR clusters: the per-cluster mapping wins,
  38. /// then the flat `[flare].orbit_cluster`, then the default. This is what makes
  39. /// one launcher config work on `dgx58-switch` and `prod4` alike.
  40. pub fn orbit_cluster_for(config: &Config, cluster: &str) -> String {
  41. let f = config.flare.as_ref();
  42. f.and_then(|f| f.clusters.get(cluster))
  43. .and_then(|c| c.orbit_cluster.clone())
  44. .or_else(|| f.and_then(|f| f.orbit_cluster.clone()))
  45. .unwrap_or_else(|| "sparks".to_string())
  46. }
  47. /// The orbit cluster a **specific** cluster is paired with, or `None` when the
  48. /// config does not say (the flat `[flare].orbit_cluster` is then only a
  49. /// fallback for `serve`, not evidence of a pairing).
  50. ///
  51. /// This is the identification rule the dashboard and `health` use: a cluster
  52. /// whose own nodes were never paired with an orbit must not be probed with the
  53. /// flat default (`sparks`) — `6nodes-switch`, `1node-*`, `ds4` and `qwen38`
  54. /// share nodes with the paired clusters, so asking one of them `--cluster
  55. /// sparks` made the sparks lane look like a lane of *that* cluster.
  56. pub fn orbit_cluster_paired(config: &Config, cluster: &str) -> Option<String> {
  57. let f = config.flare.as_ref()?;
  58. if let Some(own) = f.clusters.get(cluster) {
  59. // A per-cluster block without an orbit name inherits the flat one —
  60. // the operator already said "this cluster speaks FLARE".
  61. return own
  62. .orbit_cluster
  63. .clone()
  64. .or_else(|| f.orbit_cluster.clone());
  65. }
  66. // Once the map exists it is the source of truth: the flat block describes
  67. // the machine it was written for, and our clusters share nodes (dgx5 is
  68. // rank 0 of `dgx58-switch` AND rank 4 of `6nodes-switch`), so a flat
  69. // `sparks` would pair `6nodes-switch` too.
  70. if !f.clusters.is_empty() {
  71. return None;
  72. }
  73. let flat = f.orbit_cluster.clone()?;
  74. match f.controller_node.as_deref() {
  75. // Legacy flat block: it speaks for the cluster that holds its
  76. // controller node.
  77. Some(ctl) => config
  78. .clusters
  79. .iter()
  80. .find(|c| c.name == cluster)
  81. .is_some_and(|c| c.nodes.iter().any(|n| n.name == ctl))
  82. .then_some(flat),
  83. // No controller either: unambiguous only with a single cluster.
  84. None => (config.clusters.len() == 1 && config.clusters[0].name == cluster).then_some(flat),
  85. }
  86. }
  87. /// Extra `orbit run` flags for a cluster (per-cluster map first, then flat).
  88. /// Fabric shape is a cluster property. The prod 4-node switch uses no extra
  89. /// flag: FLARE finds the star links itself once the legacy CX7 ports are down;
  90. /// `--roce-ring` was a symptom-level fix and is retired.
  91. pub fn extra_flags_for(config: &Config, cluster: &str) -> Vec<String> {
  92. let Some(f) = config.flare.as_ref() else {
  93. return Vec::new();
  94. };
  95. let per = f
  96. .clusters
  97. .get(cluster)
  98. .map(|c| c.extra_flags.clone())
  99. .unwrap_or_default();
  100. if per.is_empty() {
  101. f.extra_flags.clone()
  102. } else {
  103. per
  104. }
  105. }
  106. /// Local package mirror dir for a cluster (per-cluster map first).
  107. pub(crate) fn local_dir_for(config: &Config, cluster: &str) -> Option<String> {
  108. let f = config.flare.as_ref()?;
  109. f.clusters
  110. .get(cluster)
  111. .and_then(|c| c.local_dir.clone())
  112. .or_else(|| f.local_dir.clone())
  113. }
  114. /// The orbit-side name for one of our nodes (rank order → `orbit_nodes`,
  115. /// falling back to the cluster's own name).
  116. fn orbit_node_name(config: &Config, cluster: &str, node: &Node) -> String {
  117. let f = config.flare.as_ref();
  118. let per_cluster = f
  119. .and_then(|f| f.clusters.get(cluster))
  120. .map(|c| c.orbit_nodes.as_slice())
  121. .filter(|v| !v.is_empty());
  122. per_cluster
  123. .or_else(|| f.map(|f| f.orbit_nodes.as_slice()))
  124. .and_then(|v| v.get(node.rank as usize).cloned())
  125. .unwrap_or_else(|| node.name.clone())
  126. }
  127. pub(crate) fn binary(config: &Config) -> String {
  128. config
  129. .flare
  130. .as_ref()
  131. .and_then(|f| f.binary.clone())
  132. .unwrap_or_else(|| "flare".to_string())
  133. }
  134. /// `flare download <package> --revision <rev>` — prints the package path.
  135. pub fn download_cmd(config: &Config, recipe: &Recipe) -> Result<String> {
  136. let f = recipe
  137. .flare
  138. .as_ref()
  139. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  140. let mut cmd = format!("{} download {}", binary(config), shq(&f.package));
  141. if let Some(rev) = &f.revision {
  142. cmd.push_str(&format!(" --revision {}", shq(rev)));
  143. }
  144. // A prepared package is built for one TP size; Spark has one visible GPU,
  145. // so `flare download` defaults to TP1 and rejects a TP4 package
  146. // ("package exports no deployment with tensor-parallel size 1").
  147. // Use the launch-node count as the package's TP (a recipe with
  148. // `required_nodes` set is a TP-N package; base recipes fall back to
  149. // tensor_parallel_size).
  150. let tp = recipe
  151. .required_nodes
  152. .unwrap_or(recipe.tensor_parallel_size)
  153. .max(1);
  154. cmd.push_str(&format!(" --tensor-parallel {}", tp));
  155. // NOTE: `flare download` does NOT accept the approval flags (it fails with
  156. // "unexpected argument") — the signature gate lives on `orbit run`, which
  157. // is where `run_cmd` passes them. A download has always been accepted
  158. // unattended, so approving a publisher here would break the serve.
  159. Ok(cmd)
  160. }
  161. /// `flare orbit run "<node>:<package>" --cluster <c> --name <n> … --detach`.
  162. pub fn run_cmd(
  163. config: &Config,
  164. recipe: &Recipe,
  165. our_cluster: &str,
  166. orbit_cluster: &str,
  167. controller: &str,
  168. package: &str,
  169. ) -> Result<String> {
  170. let f = recipe
  171. .flare
  172. .as_ref()
  173. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  174. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  175. let mut cmd = format!(
  176. "{} orbit run {} --cluster {} --name {} --host 0.0.0.0 --port {} --detach",
  177. binary(config),
  178. shq(&format!("{controller}:{package}")),
  179. shq(orbit_cluster),
  180. shq(&model_name),
  181. http_port(recipe),
  182. );
  183. if let Some(k) = config.flare.as_ref().and_then(|c| c.accept_key.clone()) {
  184. cmd.push_str(&format!(" --dangerously-approve-key {}", shq(&k)));
  185. }
  186. if let Some(p) = config
  187. .flare
  188. .as_ref()
  189. .and_then(|c| c.accept_publisher.clone())
  190. {
  191. cmd.push_str(&format!(" --dangerously-accept-publisher {}", shq(&p)));
  192. }
  193. for flag in &f.serve_flags {
  194. cmd.push_str(&format!(" {}", shq(flag)));
  195. }
  196. // fabric shape is a cluster property (a switch-emulated ring, a DAC chain)
  197. for flag in extra_flags_for(config, our_cluster) {
  198. cmd.push_str(&format!(" {}", shq(&flag)));
  199. }
  200. Ok(cmd)
  201. }
  202. /// `flare orbit stop <name> --cluster <c>`.
  203. pub fn stop_cmd(config: &Config, recipe: &Recipe, orbit_cluster: &str) -> Result<String> {
  204. let f = recipe
  205. .flare
  206. .as_ref()
  207. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  208. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  209. Ok(format!(
  210. "{} orbit stop {} --cluster {}",
  211. binary(config),
  212. shq(&model_name),
  213. shq(orbit_cluster),
  214. ))
  215. }
  216. /// `flare orbit attach <name> --cluster <c>` (log tail; the CLI's `logs`).
  217. pub fn attach_cmd(config: &Config, recipe: &Recipe, orbit_cluster: &str) -> Result<String> {
  218. let f = recipe
  219. .flare
  220. .as_ref()
  221. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  222. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  223. Ok(format!(
  224. "{} orbit attach {} --cluster {}",
  225. binary(config),
  226. shq(&model_name),
  227. shq(orbit_cluster),
  228. ))
  229. }
  230. /// Preflight on every launch node: the agent installed and the orbit cluster
  231. /// visible. Runs before any download or launch — a peer that never paired
  232. /// otherwise fails minutes into a 500 GB fetch.
  233. ///
  234. /// Only the controller has the orbit file with `[cluster.<name>]` — a peer
  235. /// just needs the binary and a live agent, so the cluster check is added
  236. /// there and nowhere else.
  237. pub fn preflight_cmd(config: &Config, orbit_cluster: &str, controller: bool) -> String {
  238. let base = format!(
  239. "{} --version >/dev/null && systemctl --user is-active flare-agent >/dev/null",
  240. binary(config)
  241. );
  242. if controller {
  243. format!(
  244. "{base} && {} orbit status --cluster {} >/dev/null",
  245. binary(config),
  246. shq(orbit_cluster),
  247. )
  248. } else {
  249. base
  250. }
  251. }
  252. /// Mirror a package's files into the controller's local dir and return the
  253. /// local primary path.
  254. ///
  255. /// FLARE names every companion by its sha256, so "present and the same size"
  256. /// is a sound consistency check — a truncated or stale file re-copies, and an
  257. /// unchanged package costs a `stat` per file. The primary (metadata) is tiny
  258. /// and always refreshed.
  259. pub fn mirror_cmd(local_dir: &str, remote_pkg: &str) -> String {
  260. let dir = shq(local_dir);
  261. let pkg = shq(remote_pkg);
  262. // New packages keep companion data in nested `weights/` and
  263. // `deployments/` directories, so a flat file loop is wrong: copy the
  264. // package directory recursively, and skip the copy when the cache root
  265. // is already the package directory itself.
  266. format!(
  267. "set -e; src=$(dirname {pkg}); dst={dir}; \
  268. if [ \"$src\" = \"$dst\" ]; then echo {pkg}; exit 0; fi; \
  269. mkdir -p \"$dst\"; \
  270. rsync -a --delete --exclude '.sm121-flare-package.json' \"$src\"/ \"$dst\"/; \
  271. echo \"$dst/$(basename {pkg})\""
  272. )
  273. }
  274. /// Pick the package path out of `flare download` output: it prints progress
  275. /// lines ("Downloading model file: …") and finally the resolved package path
  276. /// ending in `.safetensors`. Taking the last line blindly handed `orbit run`
  277. /// a progress line and the group died silently — so scan backwards for a path.
  278. pub fn parse_package_path(stdout: &str) -> Option<String> {
  279. // Cached downloads print only a "Resolved package: … / model.safetensors"
  280. // line, so require an absolute path and ignore the metadata lines.
  281. stdout
  282. .lines()
  283. .rev()
  284. .map(str::trim)
  285. .find(|l| l.starts_with('/') && l.ends_with(".safetensors"))
  286. .map(str::to_string)
  287. }
  288. fn head(cluster: &crate::config::Cluster) -> Result<&Node> {
  289. cluster
  290. .nodes
  291. .iter()
  292. .find(|n| n.rank == 0)
  293. .ok_or_else(|| anyhow::anyhow!("no head node (rank 0) in cluster"))
  294. }
  295. /// Launch a FLARE recipe: preflight, package download on the controller, then
  296. /// `orbit run` detached. Readiness (`/health`, `/v1/models`) is polled by the
  297. /// caller, like every other engine.
  298. pub async fn serve(
  299. config: &Config,
  300. recipe: &Recipe,
  301. cluster: &crate::config::Cluster,
  302. dry_run: bool,
  303. ) -> Result<()> {
  304. let controller = head(cluster)?;
  305. let orbit = orbit_cluster_for(config, &cluster.name);
  306. if dry_run {
  307. println!(
  308. " [dry-run] preflight on {} nodes (cluster check on the controller only): {}",
  309. cluster.nodes.len(),
  310. preflight_cmd(config, &orbit, true)
  311. );
  312. } else {
  313. for node in &cluster.nodes {
  314. let pre = preflight_cmd(config, &orbit, node.rank == 0);
  315. if let Err(e) = crate::ssh::run_ssh(config, node, pre, false).await {
  316. bail!(
  317. "FLARE preflight failed on {}: {e} — install `flare`, then `flare agent install` and pair the node with the controller",
  318. node.name
  319. );
  320. }
  321. }
  322. info!("FLARE agents ok on {} node(s)", cluster.nodes.len());
  323. }
  324. let id = crate::native::flare_package::package_id(recipe)?;
  325. // Identification: a complete local mirror of this exact package (repo,
  326. // revision, TP, and the CURRENT prepared version) means the download
  327. // verification and the 500 GB recopy are both unnecessary.
  328. let mut package: String;
  329. if !dry_run {
  330. if let Some(local) =
  331. crate::native::flare_package::local_package(config, recipe, &cluster.name, controller)
  332. .await?
  333. {
  334. info!(
  335. "FLARE package already local on {}; skipping download and mirror",
  336. controller.name
  337. );
  338. package = local;
  339. } else {
  340. let dl = download_cmd(config, recipe)?;
  341. let out = crate::ssh::run_ssh_long_running(config, controller, dl, dry_run).await?;
  342. package = parse_package_path(&out).unwrap_or_default();
  343. // A cached download prints only "Resolved package: … / model.safetensors"
  344. // and no final path; resolve the default prepared model from Flare's
  345. // model cache instead of failing after a successful download.
  346. if package.is_empty() {
  347. let cache_cmd = format!(
  348. "{} models list --json 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); m=d.get(\"models\") or []; vers=(m[0].get(\"versions\") or []) if m else []; v=vers[0] if vers else dict(); print(d.get(\"cache\",\"\") + \"/models/\" + m[0].get(\"model_key\",\"\") + \"/\" + v.get(\"version\",\"\"))'",
  349. binary(config)
  350. );
  351. let dir = crate::ssh::run_ssh(config, controller, cache_cmd, false).await?;
  352. let dir = dir.trim();
  353. if !dir.is_empty() {
  354. let find = format!(
  355. "find {} -maxdepth 1 -name '*.safetensors' -print -quit",
  356. shq(dir)
  357. );
  358. let primary = crate::ssh::run_ssh(config, controller, find, false).await?;
  359. package = primary.trim().to_string();
  360. }
  361. }
  362. if package.is_empty() {
  363. bail!(
  364. "flare download printed no package path on {}",
  365. controller.name
  366. );
  367. }
  368. // Local mirror: launch from the controller's own disk when configured —
  369. // engram rows are read at request time, so a shared mount is the wrong
  370. // place for the package even with a RAM row cache.
  371. if let Some(dir) = local_dir_for(config, &cluster.name) {
  372. let dir = dir.replace('~', "$HOME");
  373. // Version from the NAS package directory (the local mirror root
  374. // has no version-named subdir).
  375. let version = crate::native::flare_package::version_of(&package);
  376. let cmd = mirror_cmd(&dir, &package);
  377. let out =
  378. crate::ssh::run_ssh_long_running(config, controller, cmd, dry_run).await?;
  379. let local = out.lines().last().unwrap_or_default().trim().to_string();
  380. if local.is_empty() {
  381. bail!("flare local mirror produced no path on {}", controller.name);
  382. }
  383. package = local;
  384. crate::native::flare_package::write_marker(
  385. config, controller, &dir, &package, &id, &version,
  386. )
  387. .await?;
  388. }
  389. }
  390. } else {
  391. // Dry-run: print the download and mirror commands, no SSH.
  392. let dl = download_cmd(config, recipe)?;
  393. package = crate::ssh::run_ssh_long_running(config, controller, dl, true).await?;
  394. package = parse_package_path(&package).unwrap_or_default();
  395. if let Some(dir) = local_dir_for(config, &cluster.name) {
  396. let dir = dir.replace('~', "$HOME");
  397. let cmd = mirror_cmd(&dir, &package);
  398. crate::ssh::run_ssh_long_running(config, controller, cmd, true).await?;
  399. }
  400. }
  401. let orbit_name = orbit_node_name(config, &cluster.name, controller);
  402. let run = run_cmd(config, recipe, &cluster.name, &orbit, &orbit_name, &package)?;
  403. crate::ssh::run_ssh_long_running(config, controller, run.clone(), dry_run).await?;
  404. if dry_run {
  405. println!(" [dry-run] {}: {run}", controller.name);
  406. } else {
  407. info!("FLARE group launched on {}", controller.name);
  408. }
  409. Ok(())
  410. }
  411. /// Post-launch: wait for the API (the package load takes many minutes) and
  412. /// point the operator at the log command.
  413. pub async fn after_serve(
  414. config: &Config,
  415. recipe: &Recipe,
  416. cluster: &crate::config::Cluster,
  417. recipe_name: &str,
  418. opts: &ServeOpts,
  419. follow: bool,
  420. ) -> Result<()> {
  421. let port = http_port(recipe);
  422. if !opts.no_wait_ready {
  423. crate::readiness::wait_engine(config, cluster, port, 30 * 60, recipe_name).await?;
  424. }
  425. if follow {
  426. info!(
  427. "FLARE serving on {}:{} — logs: sm121 logs --recipe {recipe_name}",
  428. cluster
  429. .nodes
  430. .first()
  431. .map(|n| n.ssh_ip.as_str())
  432. .unwrap_or("?"),
  433. port
  434. );
  435. }
  436. Ok(())
  437. }
  438. /// Stop the FLARE group on the controller.
  439. pub async fn stop(
  440. config: &Config,
  441. recipe: &Recipe,
  442. cluster: &crate::config::Cluster,
  443. dry_run: bool,
  444. ) -> Result<()> {
  445. let controller = head(cluster)?;
  446. let cmd = stop_cmd(config, recipe, &orbit_cluster_for(config, &cluster.name))?;
  447. crate::ssh::run_ssh(config, controller, cmd, dry_run).await?;
  448. Ok(())
  449. }
  450. #[cfg(test)]
  451. #[path = "flare_tests.rs"]
  452. mod tests;