types.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. use serde::Deserialize;
  2. use std::collections::HashMap;
  3. pub use crate::pre_serve::{
  4. BeforeServeTable, DistributedBackend, ModsTable, OnFailure, PreServeCommand, RecipeArgs,
  5. };
  6. #[derive(Clone, Debug, Deserialize)]
  7. pub struct Node {
  8. pub name: String,
  9. pub ssh_ip: String,
  10. pub ib_ip: String,
  11. pub rank: u32,
  12. #[serde(default)]
  13. pub host_key: Option<String>,
  14. #[serde(default)]
  15. pub entity: Option<String>,
  16. }
  17. #[derive(Clone, Debug, Deserialize)]
  18. pub struct NcclConfig {
  19. pub ib_hca: String,
  20. pub socket_ifname: String,
  21. pub ib_gid_index: u32,
  22. pub ib_disable: u32,
  23. #[serde(default)]
  24. pub gloo_socket_ifname: Option<String>,
  25. #[serde(default)]
  26. pub ucx_net_devices: Option<String>,
  27. #[serde(default)]
  28. pub nccl_cross_nic: Option<u32>,
  29. #[serde(default)]
  30. pub nccl_proto: Option<String>,
  31. #[serde(default)]
  32. pub nccl_p2p_level: Option<String>,
  33. }
  34. #[derive(Clone, Debug, Deserialize)]
  35. pub struct Cluster {
  36. pub name: String,
  37. pub master_ib_ip: String,
  38. pub nodes: Vec<Node>,
  39. #[serde(default)]
  40. pub extra_entities: Vec<String>,
  41. }
  42. #[derive(Clone, Deserialize)]
  43. pub struct Config {
  44. pub ssh_user: String,
  45. pub ssh_key_path: std::path::PathBuf,
  46. pub clusters: Vec<Cluster>,
  47. #[serde(default)]
  48. pub nccl: HashMap<String, NcclConfig>,
  49. #[serde(default)]
  50. pub hf_cache_dir: Option<String>,
  51. #[serde(default = "default_init_port")]
  52. pub init_port: u16,
  53. #[serde(default)]
  54. pub before_serve: HashMap<String, BeforeServeTable>,
  55. #[serde(default)]
  56. pub exec_aliases: HashMap<String, String>,
  57. #[serde(default)]
  58. pub home_assistant: Option<HomeAssistantConfig>,
  59. #[serde(default)]
  60. pub global_mods_dir: Option<std::path::PathBuf>,
  61. /// Drop page cache before starting containers.
  62. #[serde(default = "default_drop_caches")]
  63. pub drop_caches: bool,
  64. /// Named mount-alias registry — host paths referenced by name from recipe
  65. /// `[[extra_mounts]]` entries. Lets recipes stay portable (reference an
  66. /// alias like `jit_cache`) while the operator sets the real path here.
  67. /// A recipe may also carry a `host` default; the alias, when present,
  68. /// overrides it.
  69. #[serde(default)]
  70. pub mount_aliases: HashMap<String, String>,
  71. }
  72. #[derive(Clone, Deserialize)]
  73. pub struct HomeAssistantConfig {
  74. pub url: String,
  75. pub token: String,
  76. #[serde(default = "default_shutdown_delay")]
  77. pub shutdown_delay_seconds: u64,
  78. }
  79. fn default_shutdown_delay() -> u64 {
  80. 30
  81. }
  82. fn default_init_port() -> u16 {
  83. 25000
  84. }
  85. fn default_port() -> u16 {
  86. 30000
  87. }
  88. fn default_engine() -> String {
  89. "vllm".to_string()
  90. }
  91. fn default_shm_size() -> String {
  92. String::new()
  93. }
  94. fn default_tensor_parallel_size() -> u32 {
  95. 1
  96. }
  97. fn default_sync_image() -> bool {
  98. true
  99. }
  100. fn default_drop_caches() -> bool {
  101. true
  102. }
  103. #[derive(Clone, Deserialize)]
  104. pub struct Recipe {
  105. #[serde(default = "default_engine")]
  106. pub engine: String,
  107. pub name: String,
  108. #[serde(default)]
  109. pub docker_image: String,
  110. #[serde(default)]
  111. pub model: String,
  112. #[serde(default = "default_port")]
  113. pub port: u16,
  114. #[serde(default)]
  115. pub required_nodes: Option<u32>,
  116. #[serde(default)]
  117. pub pipeline_parallel_size: Option<u32>,
  118. #[serde(default = "default_tensor_parallel_size")]
  119. pub tensor_parallel_size: u32,
  120. #[serde(default)]
  121. pub args: RecipeArgs,
  122. #[serde(default)]
  123. pub env: HashMap<String, String>,
  124. #[serde(default)]
  125. pub cluster: Option<String>,
  126. #[serde(default)]
  127. pub entrypoint: Vec<String>,
  128. pub distributed_backend: Option<DistributedBackend>,
  129. #[serde(default = "default_shm_size")]
  130. pub shm_size: String,
  131. #[serde(default)]
  132. pub before_serve: Vec<PreServeCommand>,
  133. /// Commands to run on each node **after** stopping containers
  134. /// (`sm121 stop`). Same shape as [`before_serve`]: raw shell commands
  135. /// (`Bare`/`Configured`) or built-in script aliases (`Script`). Runs
  136. /// per-node in parallel, sequentially within a node. Typical use:
  137. /// `after_stop = [{ script = "restore_dgx_services" }]` to bring back
  138. /// services that `before_serve` stopped.
  139. #[serde(default)]
  140. pub after_stop: Vec<PreServeCommand>,
  141. /// Whether this recipe should always be served single-node on the first
  142. /// free node found (solo mode). When set, `sm121 serve` ignores
  143. /// `required_nodes` / multi-node cluster selection and launches exactly
  144. /// one container with no distributed flags. The CLI `--solo`/`--no-solo`
  145. /// flag overrides this per-invocation.
  146. #[serde(default)]
  147. pub solo: Option<bool>,
  148. #[serde(default)]
  149. pub mods: ModsTable,
  150. #[serde(default = "default_sync_image")]
  151. pub sync_image: bool,
  152. /// Build directory for docker-images/ (default: derived from recipe name).
  153. #[serde(default)]
  154. pub docker_image_dir: Option<String>,
  155. /// Recipe-driven overrides for the image's `versions.env`. Each entry is
  156. /// merged over the image's pins at build time (recipe wins) and folded
  157. /// into the build checksum, so changing a pin here triggers a rebuild
  158. /// without editing `docker-images/<dir>/versions.env`. Keys must match
  159. /// the `versions.env` names the image's `Dockerfile` declares as `ARG`.
  160. #[serde(default)]
  161. pub build: RecipeBuild,
  162. /// When set, the launcher builds a /tmp composed-model dir per-node
  163. /// (symlinks into the HF cache + generated files), bind-mounts it at
  164. /// `mount`, and rewrites `model` to that path. Lets a recipe combine
  165. /// multiple HF repos (e.g. a base checkpoint + an expert overlay)
  166. /// without mutating the HF cache. See [`ComposedModel`].
  167. #[serde(default)]
  168. pub composed_model: Option<ComposedModel>,
  169. /// Extra bind-mounts to add to `docker run` (e.g. persistent JIT cache).
  170. /// Each entry resolves its host path from `Config.mount_aliases` when
  171. /// `alias` is set and present there (alias wins); otherwise falls back to
  172. /// `host` (with `~` expansion). At least one of `alias`/`host` is required.
  173. #[serde(default)]
  174. pub extra_mounts: Vec<ExtraMount>,
  175. }
  176. /// Recipe-level build configuration. Currently only `versions` (a map of
  177. /// `versions.env` pin overrides) is supported; the launcher merges these
  178. /// over the image's own `versions.env` before building.
  179. #[derive(Clone, Debug, Default, Deserialize)]
  180. pub struct RecipeBuild {
  181. /// Pins merged over the image's `versions.env` (recipe wins). Keys are
  182. /// `versions.env` variable names (e.g. `VLLM_COMMIT`); values are the
  183. /// exact version/tag/commit. See [`Recipe::build`].
  184. #[serde(default)]
  185. pub versions: HashMap<String, String>,
  186. }
  187. /// A composed model directory: the launcher builds a `/tmp` dir per-node
  188. /// with symlinks into the HF cache (container-side absolute paths) +
  189. /// generated files, then bind-mounts it into the container. Lets a recipe
  190. /// combine multiple HF repos (e.g. a base checkpoint + an expert overlay)
  191. /// without mutating the HF cache.
  192. ///
  193. /// Symlinks point to `/root/.cache/huggingface/...` (the container-side HF
  194. /// cache mount), not the host-side path. They are dangling on the host but
  195. /// resolve correctly inside the container where the HF cache is mounted at
  196. /// that exact path — avoiding the host↔container mount-root mismatch.
  197. #[derive(Clone, Debug, Default, Deserialize)]
  198. pub struct ComposedModel {
  199. /// Container-side mount target for the composed dir (e.g. `/root/model`).
  200. /// `recipe.model` is rewritten to this path at serve time.
  201. pub mount: String,
  202. /// Source repos to symlink into the composed dir. Later entries win
  203. /// (overwrite earlier symlinks), so order matters for overlays.
  204. #[serde(default)]
  205. pub sources: Vec<ComposedSource>,
  206. /// Real files (not symlinks) to write into the composed dir.
  207. #[serde(default)]
  208. pub files: Vec<ComposedFile>,
  209. }
  210. /// A source repo for [`ComposedModel`]. The repo's HF cache snapshot is
  211. /// symlinked into the composed dir, optionally filtered by `glob` and
  212. /// `exclude`.
  213. #[derive(Clone, Debug, Default, Deserialize)]
  214. pub struct ComposedSource {
  215. /// HF Hub repo id (e.g. `aidendle94/GLM-5.2-MXFP4-Experts-GPTQ`).
  216. pub repo: String,
  217. /// Optional glob to filter which snapshot files are symlinked (e.g.
  218. /// `L*.safetensors` — only expert weight files). Empty/omitted = all
  219. /// files. Only glob-safe characters are allowed (alphanumeric + `*?._-/`).
  220. #[serde(default)]
  221. pub glob: Option<String>,
  222. /// Optional list of path patterns to skip (matched against the file's
  223. /// path relative to the snapshot root, via shell `case`). Lets a later
  224. /// source overlay skip specific files so an earlier source's version
  225. /// survives — e.g. `exclude = ["L5.safetensors"]` keeps the base
  226. /// checkpoint's `L5.safetensors` instead of the overlay's. Same allowed
  227. /// characters as `glob` (alphanumeric + `*?._-/`).
  228. #[serde(default)]
  229. pub exclude: Option<Vec<String>>,
  230. }
  231. /// A generated file written into the composed dir (not a symlink).
  232. #[derive(Clone, Debug, Default, Deserialize)]
  233. pub struct ComposedFile {
  234. /// Filename in the composed dir (e.g. `nf4_layers.json`).
  235. pub name: String,
  236. /// Raw file content (written verbatim via base64).
  237. pub content: String,
  238. }
  239. impl Config {
  240. pub fn resolved_hf_cache_dir(&self) -> std::path::PathBuf {
  241. if let Some(ref dir) = self.hf_cache_dir {
  242. return std::path::PathBuf::from(dir);
  243. }
  244. let home = if self.ssh_user == "root" {
  245. "/root".to_string()
  246. } else {
  247. format!("/home/{}", self.ssh_user)
  248. };
  249. std::path::PathBuf::from(format!("{home}/.cache/huggingface"))
  250. }
  251. }
  252. /// A recipe-level extra bind-mount for `docker run`.
  253. ///
  254. /// Two mutually exclusive kinds:
  255. /// - **Static** (`alias`/`host`): host path is known at recipe-write time.
  256. /// `alias` resolves against `Config.mount_aliases` and, when present there,
  257. /// overrides `host`. If the alias is absent from config, `host` is used as
  258. /// the default. `~` (and `~/`) at the start of the resolved path is expanded
  259. /// to the remote SSH user's home. At least one of `alias` or `host` must be
  260. /// set.
  261. /// - **Model path reference** (`repo` + optional `subfolder`): NOT a bind-mount.
  262. /// The path is resolved **at serve time, per node**, from the node's HF
  263. /// cache: `<hf_cache>/hub/<model_cache_dir_name(repo)>/snapshots/<refs/main>/<subfolder>`.
  264. /// `refs/main` always points at the latest commit, so the path survives
  265. /// `huggingface-cli download` updates — no fragile symlink. The recipe
  266. /// references this path via a `{{<container>}}` placeholder in args/env
  267. /// (e.g. `--speculative-config`), which the launcher substitutes per-node
  268. /// before building the docker command. The full HF cache is already mounted
  269. /// at `/root/.cache/huggingface`, so the resolved path's symlinks resolve
  270. /// correctly (a separate bind-mount would break them).
  271. /// If the subfolder is absent on a node, serve aborts with a hint to
  272. /// `huggingface-cli download <repo>` (the launcher never downloads).
  273. #[derive(Clone, Debug, Default, Deserialize)]
  274. pub struct ExtraMount {
  275. /// Name resolved via `Config.mount_aliases`; overrides `host` when present.
  276. /// Mutually exclusive with `repo`.
  277. #[serde(default)]
  278. pub alias: Option<String>,
  279. /// Fallback host path (used when the alias is not in `mount_aliases`).
  280. /// Mutually exclusive with `repo`.
  281. #[serde(default)]
  282. pub host: Option<String>,
  283. /// HF Hub repo id, e.g. `aidendle94/GLM-5.2-Hybrid-FP8-MXFP4`. When set,
  284. /// the path is resolved at serve time from the node's HF cache and
  285. /// substituted into args/env via `{{<container>}}` (so `alias`/`host`
  286. /// must be empty).
  287. #[serde(default)]
  288. pub repo: Option<String>,
  289. /// Subfolder within the repo snapshot (e.g. `mtp-draft`).
  290. /// Empty/omitted = the whole snapshot dir.
  291. #[serde(default)]
  292. pub subfolder: Option<String>,
  293. /// For static mounts: the container-side mount target. For repo-based
  294. /// path references: the placeholder name (e.g. `mtp_draft`) that gets
  295. /// substituted as `{{mtp_draft}}` in args/env.
  296. pub container: String,
  297. /// Mount read-only (`:ro`). Only for static mounts.
  298. #[serde(default)]
  299. pub read_only: bool,
  300. /// If set, inject `-e <env>=<container path>` so recipes don't hardcode
  301. /// the path in two places (mount + env). Only for static mounts.
  302. #[serde(default)]
  303. pub env: Option<String>,
  304. }
  305. /// Directory name a repo id maps to inside `<hf_cache>/hub/`.
  306. /// `org/name` -> `models--org--name` (HF convention: prefix `models--`,
  307. /// replace `/` with `--`). Pure & deterministic; used by the model-subfolder
  308. /// mount resolver.
  309. pub fn model_cache_dir_name(repo: &str) -> String {
  310. format!("models--{}", repo.replace('/', "--"))
  311. }
  312. /// Expand a leading `~` / `~/` to the remote SSH user's home directory.
  313. /// Paths without a leading `~` are returned unchanged. Used for host paths
  314. /// before they are sent to `docker run -v`.
  315. pub fn expand_tilde(path: &str, ssh_user: &str) -> String {
  316. let home = if ssh_user == "root" {
  317. "/root".to_string()
  318. } else {
  319. format!("/home/{}", ssh_user)
  320. };
  321. if let Some(rest) = path.strip_prefix("~/") {
  322. format!("{}/{}", home, rest)
  323. } else if path == "~" {
  324. home
  325. } else {
  326. path.to_string()
  327. }
  328. }
  329. /// Resolve a recipe `ExtraMount`'s host path: the config alias wins over the
  330. /// recipe `host` default; if neither is set, error. `~` is expanded.
  331. pub fn resolve_mount_host(
  332. alias: Option<&str>,
  333. host: Option<&str>,
  334. mount_aliases: &HashMap<String, String>,
  335. ssh_user: &str,
  336. container: &str,
  337. ) -> anyhow::Result<String> {
  338. let raw = alias
  339. .and_then(|a| {
  340. if a.trim().is_empty() {
  341. None
  342. } else {
  343. mount_aliases.get(a).map(String::as_str)
  344. }
  345. })
  346. .or_else(|| host.filter(|h| !h.trim().is_empty()))
  347. .ok_or_else(|| {
  348. anyhow::anyhow!(
  349. "extra_mount '{}' has neither a resolvable alias nor a host path",
  350. container
  351. )
  352. })?;
  353. Ok(expand_tilde(raw, ssh_user))
  354. }
  355. #[cfg(test)]
  356. mod tests {
  357. use super::{expand_tilde, model_cache_dir_name, resolve_mount_host};
  358. use std::collections::HashMap;
  359. #[test]
  360. fn model_cache_dir_name_maps_repo_id() {
  361. assert_eq!(
  362. model_cache_dir_name("aidendle94/GLM-5.2-Hybrid-FP8-MXFP4"),
  363. "models--aidendle94--GLM-5.2-Hybrid-FP8-MXFP4"
  364. );
  365. assert_eq!(model_cache_dir_name("org/name"), "models--org--name");
  366. assert_eq!(model_cache_dir_name("bare"), "models--bare");
  367. }
  368. #[test]
  369. fn expand_tilde_user_and_root() {
  370. assert_eq!(expand_tilde("~/.cache", "kilork"), "/home/kilork/.cache");
  371. assert_eq!(expand_tilde("~/", "kilork"), "/home/kilork/");
  372. assert_eq!(expand_tilde("~", "root"), "/root");
  373. assert_eq!(expand_tilde("/abs/path", "kilork"), "/abs/path");
  374. assert_eq!(expand_tilde("rel/path", "kilork"), "rel/path");
  375. }
  376. #[test]
  377. fn resolve_mount_alias_wins_over_host() {
  378. let aliases = HashMap::from([("jit_cache".to_string(), "/mnt/nas/jit".to_string())]);
  379. // alias present in config → alias wins, host ignored
  380. let r = resolve_mount_host(Some("jit_cache"), Some("~/.cache/jit"), &aliases, "u", "/c");
  381. assert_eq!(r.unwrap(), "/mnt/nas/jit");
  382. }
  383. #[test]
  384. fn resolve_mount_falls_back_to_host_with_tilde() {
  385. let aliases = HashMap::new();
  386. // alias not in config → host default used, with ~ expansion
  387. let r = resolve_mount_host(
  388. Some("jit_cache"),
  389. Some("~/.cache/jit"),
  390. &aliases,
  391. "kilork",
  392. "/c",
  393. );
  394. assert_eq!(r.unwrap(), "/home/kilork/.cache/jit");
  395. }
  396. #[test]
  397. fn resolve_mount_host_only() {
  398. let aliases = HashMap::new();
  399. let r = resolve_mount_host(None, Some("/var/cache"), &aliases, "u", "/c");
  400. assert_eq!(r.unwrap(), "/var/cache");
  401. }
  402. #[test]
  403. fn resolve_mount_neither_errors() {
  404. let aliases = HashMap::new();
  405. let r = resolve_mount_host(None, None, &aliases, "u", "/c");
  406. assert!(r.is_err());
  407. }
  408. }