flare.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  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, Recipe};
  27. use crate::docker::{ServeOpts, shq};
  28. use crate::native::flare_orbit::{controller_for, extra_flags_for};
  29. // Re-exported: the driver is the FLARE entry point the rest of the launcher uses.
  30. pub use crate::native::flare_orbit::{
  31. local_dir_for, orbit_cluster_for, orbit_cluster_paired, orbit_node_name,
  32. };
  33. /// FLARE API port on the controller (recipe `[flare].http_port`, else the
  34. /// cluster default 30000 — the same port every other lane serves on).
  35. pub fn http_port(recipe: &Recipe) -> u16 {
  36. recipe
  37. .flare
  38. .as_ref()
  39. .and_then(|f| f.http_port)
  40. .unwrap_or(30000)
  41. }
  42. pub(crate) fn binary(config: &Config) -> String {
  43. config
  44. .flare
  45. .as_ref()
  46. .and_then(|f| f.binary.clone())
  47. .unwrap_or_else(|| "flare".to_string())
  48. }
  49. /// `flare download <package> --revision <rev>` — prints the package path.
  50. pub fn download_cmd(config: &Config, recipe: &Recipe) -> Result<String> {
  51. let f = recipe
  52. .flare
  53. .as_ref()
  54. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  55. let mut cmd = format!("{} download {}", binary(config), shq(&f.package));
  56. if let Some(rev) = &f.revision {
  57. cmd.push_str(&format!(" --revision {}", shq(rev)));
  58. }
  59. // A prepared package is built for one TP size; Spark has one visible GPU,
  60. // so `flare download` defaults to TP1 and rejects a TP4 package
  61. // ("package exports no deployment with tensor-parallel size 1").
  62. // Use the launch-node count as the package's TP (a recipe with
  63. // `required_nodes` set is a TP-N package; base recipes fall back to
  64. // tensor_parallel_size).
  65. let tp = recipe
  66. .required_nodes
  67. .unwrap_or(recipe.tensor_parallel_size)
  68. .max(1);
  69. cmd.push_str(&format!(" --tensor-parallel {}", tp));
  70. // NOTE: `flare download` does NOT accept the approval flags (it fails with
  71. // "unexpected argument") — the signature gate lives on `orbit run`, which
  72. // is where `run_cmd` passes them. A download has always been accepted
  73. // unattended, so approving a publisher here would break the serve.
  74. Ok(cmd)
  75. }
  76. /// `flare orbit run "<node>:<package>" --cluster <c> --name <n> … --detach`.
  77. pub fn run_cmd(
  78. config: &Config,
  79. recipe: &Recipe,
  80. our_cluster: &str,
  81. orbit_cluster: &str,
  82. controller: &str,
  83. package: &str,
  84. ) -> Result<String> {
  85. let f = recipe
  86. .flare
  87. .as_ref()
  88. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  89. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  90. let mut cmd = format!(
  91. "{} orbit run {} --cluster {} --name {} --host 0.0.0.0 --port {} --detach",
  92. binary(config),
  93. shq(&format!("{controller}:{package}")),
  94. shq(orbit_cluster),
  95. shq(&model_name),
  96. http_port(recipe),
  97. );
  98. if let Some(k) = config.flare.as_ref().and_then(|c| c.accept_key.clone()) {
  99. cmd.push_str(&format!(" --dangerously-approve-key {}", shq(&k)));
  100. }
  101. if let Some(p) = config
  102. .flare
  103. .as_ref()
  104. .and_then(|c| c.accept_publisher.clone())
  105. {
  106. cmd.push_str(&format!(" --dangerously-accept-publisher {}", shq(&p)));
  107. }
  108. for flag in &f.serve_flags {
  109. cmd.push_str(&format!(" {}", shq(flag)));
  110. }
  111. // fabric shape is a cluster property (a switch-emulated ring, a DAC chain)
  112. for flag in extra_flags_for(config, our_cluster) {
  113. cmd.push_str(&format!(" {}", shq(&flag)));
  114. }
  115. Ok(cmd)
  116. }
  117. /// `flare orbit stop <name> --cluster <c>`.
  118. pub fn stop_cmd(config: &Config, recipe: &Recipe, orbit_cluster: &str) -> Result<String> {
  119. let f = recipe
  120. .flare
  121. .as_ref()
  122. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  123. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  124. Ok(format!(
  125. "{} orbit stop {} --cluster {}",
  126. binary(config),
  127. shq(&model_name),
  128. shq(orbit_cluster),
  129. ))
  130. }
  131. /// `flare orbit attach <name> --cluster <c>` (log tail; the CLI's `logs`).
  132. pub fn attach_cmd(config: &Config, recipe: &Recipe, orbit_cluster: &str) -> Result<String> {
  133. let f = recipe
  134. .flare
  135. .as_ref()
  136. .ok_or_else(|| anyhow::anyhow!("engine=flare requires a [flare] block"))?;
  137. let model_name = f.name.clone().unwrap_or_else(|| recipe.model.clone());
  138. Ok(format!(
  139. "{} orbit attach {} --cluster {}",
  140. binary(config),
  141. shq(&model_name),
  142. shq(orbit_cluster),
  143. ))
  144. }
  145. /// Preflight on every launch node: the agent installed and the orbit cluster
  146. /// visible. Runs before any download or launch — a peer that never paired
  147. /// otherwise fails minutes into a 500 GB fetch.
  148. ///
  149. /// Only the controller has the orbit file with `[cluster.<name>]` — a peer
  150. /// just needs the binary and a live agent, so the cluster check is added
  151. /// there and nowhere else.
  152. pub fn preflight_cmd(config: &Config, orbit_cluster: &str, controller: bool) -> String {
  153. let base = format!(
  154. "{} --version >/dev/null && systemctl --user is-active flare-agent >/dev/null",
  155. binary(config)
  156. );
  157. if controller {
  158. format!(
  159. "{base} && {} orbit status --cluster {} >/dev/null",
  160. binary(config),
  161. shq(orbit_cluster),
  162. )
  163. } else {
  164. base
  165. }
  166. }
  167. /// A path for a shell command: a leading `$HOME` stays expandable (double
  168. /// quotes) while everything else is single-quoted.
  169. pub(crate) fn shell_path(p: &str) -> String {
  170. match p.strip_prefix("$HOME") {
  171. Some(rest) => format!("\"$HOME{}\"", rest.replace('"', "\\\"")),
  172. None => shq(p),
  173. }
  174. }
  175. /// Mirror a package into the controller's local dir and return the local
  176. /// primary path.
  177. ///
  178. /// The source is the package's **store root**, not `dirname(package)`: a
  179. /// current (`format 2`) package lives in a blob store, where `blobs/<sha>`
  180. /// holds the bytes once and `models/<version>/{model.safetensors,weights,…}`
  181. /// are hardlinks into it. Two consequences, both measured on the 488 GB gb10
  182. /// package:
  183. ///
  184. /// * the copy must keep hardlinks (`rsync -aH`) — a hardlink-blind copy
  185. /// expands every link and duplicates the whole package instead of mirroring
  186. /// it;
  187. /// * it must not `--delete` the destination root, which holds every other
  188. /// prepared package in the same store.
  189. ///
  190. /// A flat (legacy) package is a self-contained directory, so the destination
  191. /// is dedicated to it and `--delete` still prunes stale companions.
  192. pub fn mirror_cmd(local_dir: &str, remote_pkg: &str) -> String {
  193. use crate::native::flare_package::{Layout, layout_of, mirror_destination, mirror_source};
  194. let src = mirror_source(remote_pkg);
  195. let dst = shell_path(local_dir);
  196. let dest_pkg = shell_path(&mirror_destination(local_dir, remote_pkg));
  197. let (flags, prune) = match layout_of(remote_pkg) {
  198. Layout::BlobStore => ("-aH", ""),
  199. Layout::Flat => ("-aH", " --delete"),
  200. };
  201. format!(
  202. "set -e; src={}; dst={}; \
  203. if [ \"$src\" = \"$dst\" ]; then echo {dest_pkg}; exit 0; fi; \
  204. mkdir -p \"$dst\"; \
  205. rsync {flags}{prune} --exclude '.sm121-flare-package.json' \"$src\"/ \"$dst\"/; \
  206. echo {dest_pkg}",
  207. shq(&src),
  208. dst,
  209. )
  210. }
  211. /// Pick the package path out of `flare download` output: it prints progress
  212. /// lines ("Downloading model file: …") and finally the resolved package path
  213. /// ending in `.safetensors`. Taking the last line blindly handed `orbit run`
  214. /// a progress line and the group died silently — so scan backwards for a path.
  215. pub fn parse_package_path(stdout: &str) -> Option<String> {
  216. // Cached downloads print only a "Resolved package: … / model.safetensors"
  217. // line, so require an absolute path and ignore the metadata lines.
  218. stdout
  219. .lines()
  220. .rev()
  221. .map(str::trim)
  222. .find(|l| l.starts_with('/') && l.ends_with(".safetensors"))
  223. .map(str::to_string)
  224. }
  225. /// Launch a FLARE recipe: preflight, package download on the controller, then
  226. /// `orbit run` detached. Readiness (`/health`, `/v1/models`) is polled by the
  227. /// caller, like every other engine.
  228. pub async fn serve(
  229. config: &Config,
  230. recipe: &Recipe,
  231. cluster: &crate::config::Cluster,
  232. dry_run: bool,
  233. ) -> Result<()> {
  234. let controller = controller_for(config, cluster)?;
  235. let orbit = orbit_cluster_for(config, &cluster.name);
  236. if dry_run {
  237. println!(
  238. " [dry-run] preflight on {} nodes (cluster check on the controller only): {}",
  239. cluster.nodes.len(),
  240. preflight_cmd(config, &orbit, true)
  241. );
  242. } else {
  243. for node in &cluster.nodes {
  244. let pre = preflight_cmd(config, &orbit, node.rank == 0);
  245. if let Err(e) = crate::ssh::run_ssh(config, node, pre, false).await {
  246. bail!(
  247. "FLARE preflight failed on {}: {e} — install `flare`, then `flare agent install` and pair the node with the controller",
  248. node.name
  249. );
  250. }
  251. }
  252. info!("FLARE agents ok on {} node(s)", cluster.nodes.len());
  253. }
  254. let id = crate::native::flare_package::package_id(recipe)?;
  255. // Identification: a complete local mirror of this exact package (repo,
  256. // revision, TP, and the CURRENT prepared version) means the download
  257. // verification and the 500 GB recopy are both unnecessary.
  258. let mut package: String;
  259. if !dry_run {
  260. if let Some(local) =
  261. crate::native::flare_package::local_package(config, recipe, &cluster.name, controller)
  262. .await?
  263. {
  264. info!(
  265. "FLARE package already local on {}; skipping download and mirror",
  266. controller.name
  267. );
  268. package = local;
  269. } else {
  270. let dl = download_cmd(config, recipe)?;
  271. let out = crate::ssh::run_ssh_long_running(config, controller, dl, dry_run).await?;
  272. package = parse_package_path(&out).unwrap_or_default();
  273. // A cached download prints only "Resolved package: … / model.safetensors"
  274. // and no final path; resolve the prepared package from the Flare
  275. // store instead of failing after a successful download.
  276. if package.is_empty() {
  277. package = crate::native::flare_package::cached_package(config, recipe, controller)
  278. .await?
  279. .unwrap_or_default();
  280. }
  281. if package.is_empty() {
  282. bail!(
  283. "flare download printed no package path on {}",
  284. controller.name
  285. );
  286. }
  287. // Local mirror: launch from the controller's own disk when configured —
  288. // engram rows are read at request time, so a shared mount is the wrong
  289. // place for the package even with a RAM row cache.
  290. if let Some(dir) = local_dir_for(config, &cluster.name) {
  291. let dir = dir.replace('~', "$HOME");
  292. // Version from the NAS package directory (the local mirror root
  293. // has no version-named subdir).
  294. let version = crate::native::flare_package::version_of(&package);
  295. let cmd = mirror_cmd(&dir, &package);
  296. let out =
  297. crate::ssh::run_ssh_long_running(config, controller, cmd, dry_run).await?;
  298. let local = out.lines().last().unwrap_or_default().trim().to_string();
  299. if local.is_empty() {
  300. bail!("flare local mirror produced no path on {}", controller.name);
  301. }
  302. package = local;
  303. crate::native::flare_package::write_marker(
  304. config, controller, &dir, &package, &id, &version,
  305. )
  306. .await?;
  307. }
  308. }
  309. } else {
  310. // Dry-run: print the download and mirror commands, no SSH.
  311. let dl = download_cmd(config, recipe)?;
  312. package = crate::ssh::run_ssh_long_running(config, controller, dl, true).await?;
  313. // The path is printed by a real download; in a dry run there is none,
  314. // so show the shape (a store-relative `models/<version>/model.safetensors`,
  315. // which is what the mirror shape depends on).
  316. package = parse_package_path(&package)
  317. .unwrap_or_else(|| "<package-store>/models/<version>/model.safetensors".into());
  318. if let Some(dir) = local_dir_for(config, &cluster.name) {
  319. let dir = dir.replace('~', "$HOME");
  320. let cmd = mirror_cmd(&dir, &package);
  321. crate::ssh::run_ssh_long_running(config, controller, cmd, true).await?;
  322. }
  323. }
  324. let orbit_name = orbit_node_name(config, &cluster.name, controller);
  325. let run = run_cmd(config, recipe, &cluster.name, &orbit, &orbit_name, &package)?;
  326. crate::ssh::run_ssh_long_running(config, controller, run.clone(), dry_run).await?;
  327. if dry_run {
  328. println!(" [dry-run] {}: {run}", controller.name);
  329. } else {
  330. info!("FLARE group launched on {}", controller.name);
  331. }
  332. Ok(())
  333. }
  334. /// Post-launch: wait for the API (the package load takes many minutes) and
  335. /// point the operator at the log command.
  336. pub async fn after_serve(
  337. config: &Config,
  338. recipe: &Recipe,
  339. cluster: &crate::config::Cluster,
  340. recipe_name: &str,
  341. opts: &ServeOpts,
  342. follow: bool,
  343. ) -> Result<()> {
  344. let port = http_port(recipe);
  345. if !opts.no_wait_ready {
  346. crate::readiness::wait_engine(config, cluster, port, 30 * 60, recipe_name).await?;
  347. }
  348. if follow {
  349. info!(
  350. "FLARE serving on {}:{} — logs: sm121 logs --recipe {recipe_name}",
  351. cluster
  352. .nodes
  353. .first()
  354. .map(|n| n.ssh_ip.as_str())
  355. .unwrap_or("?"),
  356. port
  357. );
  358. }
  359. Ok(())
  360. }
  361. /// Stop the FLARE group on the controller.
  362. pub async fn stop(
  363. config: &Config,
  364. recipe: &Recipe,
  365. cluster: &crate::config::Cluster,
  366. dry_run: bool,
  367. ) -> Result<()> {
  368. // `orbit stop` must reach the orbit's controller, which the config may pin
  369. // to a node other than our rank 0.
  370. let controller = controller_for(config, cluster)?;
  371. let cmd = stop_cmd(config, recipe, &orbit_cluster_for(config, &cluster.name))?;
  372. crate::ssh::run_ssh(config, controller, cmd, dry_run).await?;
  373. Ok(())
  374. }
  375. #[cfg(test)]
  376. #[path = "flare_tests.rs"]
  377. mod tests;