config.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. use anyhow::{Context, Result};
  2. use serde::Deserialize;
  3. use std::collections::HashMap;
  4. use tracing::info;
  5. pub use crate::pre_serve::{BeforeServeTable, OnFailure, PreServeCommand};
  6. #[derive(Clone, 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. }
  15. #[derive(Clone, Deserialize)]
  16. pub struct NcclConfig {
  17. pub ib_hca: String,
  18. pub socket_ifname: String,
  19. pub ib_gid_index: u32,
  20. pub ib_disable: u32,
  21. }
  22. #[derive(Clone, Deserialize)]
  23. pub struct Cluster {
  24. pub name: String,
  25. pub master_ib_ip: String,
  26. pub nodes: Vec<Node>,
  27. }
  28. /// Top-level config containing cluster definitions and per-cluster
  29. /// pre-serve commands.
  30. #[derive(Clone, Deserialize)]
  31. pub struct Config {
  32. pub ssh_user: String,
  33. pub ssh_key_path: String,
  34. pub clusters: Vec<Cluster>,
  35. #[serde(default)]
  36. pub nccl: HashMap<String, NcclConfig>,
  37. #[serde(default)]
  38. pub hf_cache_dir: Option<String>,
  39. /// TCP port used for cluster init (vLLM `--master-port`,
  40. /// SGLang `--dist-init-addr` port). Default: 25000.
  41. #[serde(default = "default_init_port")]
  42. pub init_port: u16,
  43. /// Per-cluster pre-serve commands. Looked up by cluster name.
  44. #[serde(default)]
  45. pub before_serve: HashMap<String, BeforeServeTable>,
  46. /// Command aliases for `cargo run exec --alias`. Keyed by alias
  47. /// name, value is the shell command to run.
  48. #[serde(default)]
  49. pub exec_aliases: HashMap<String, String>,
  50. }
  51. fn default_init_port() -> u16 {
  52. 25000
  53. }
  54. impl Config {
  55. /// Returns the HF cache directory path for volume mounting.
  56. /// If set in config, uses that value; otherwise derives from
  57. /// `ssh_user` to produce `/home/<user>/.cache/huggingface`
  58. /// (or `/root/.cache/huggingface` for root).
  59. pub fn resolved_hf_cache_dir(&self) -> String {
  60. if let Some(ref dir) = self.hf_cache_dir {
  61. return dir.clone();
  62. }
  63. let home = if self.ssh_user == "root" {
  64. "/root".to_string()
  65. } else {
  66. format!("/home/{}", self.ssh_user)
  67. };
  68. format!("{}/.cache/huggingface", home)
  69. }
  70. }
  71. #[derive(Clone, Deserialize)]
  72. pub struct Recipe {
  73. #[serde(default = "default_engine")]
  74. pub engine: String,
  75. pub name: String,
  76. #[serde(default)]
  77. pub docker_image: String,
  78. #[serde(default)]
  79. pub model: String,
  80. #[serde(default)]
  81. pub port: u16,
  82. #[serde(default)]
  83. pub required_nodes: Option<u32>,
  84. #[serde(default)]
  85. pub pipeline_parallel_size: Option<u32>,
  86. #[serde(default = "default_tensor_parallel_size")]
  87. pub tensor_parallel_size: u32,
  88. #[serde(default)]
  89. pub args: Vec<String>,
  90. #[serde(default)]
  91. pub env: HashMap<String, String>,
  92. #[serde(default)]
  93. pub cluster: Option<String>,
  94. #[serde(default)]
  95. pub entrypoint: Vec<String>,
  96. #[serde(default = "default_distributed_backend")]
  97. pub distributed_backend: String,
  98. /// Recipe-level pre-serve commands. Appended after cluster-level
  99. /// commands from `[before_serve."<cluster>"]`.
  100. #[serde(default)]
  101. pub before_serve: Vec<PreServeCommand>,
  102. }
  103. fn default_engine() -> String {
  104. "vllm".to_string()
  105. }
  106. fn default_distributed_backend() -> String {
  107. "mp".to_string()
  108. }
  109. fn default_tensor_parallel_size() -> u32 {
  110. 1
  111. }
  112. /// Selects a cluster and its NCCL config by name, or auto-picks the first
  113. /// cluster that has NCCL config.
  114. pub fn select_cluster<'c>(
  115. config: &'c Config,
  116. cluster_name: &str,
  117. ) -> Result<(Cluster, &'c NcclConfig)> {
  118. if cluster_name != "auto" {
  119. let cluster = config
  120. .clusters
  121. .iter()
  122. .find(|c| c.name == cluster_name)
  123. .context(format!("Кластер {} не найден", cluster_name))?;
  124. let nccl = config
  125. .nccl
  126. .get(&cluster.name)
  127. .context("NCCL конфиг не найден")?;
  128. return Ok((cluster.clone(), nccl));
  129. }
  130. for cluster in &config.clusters {
  131. if let Some(nccl) = config.nccl.get(&cluster.name) {
  132. info!("✅ Автоподбор кластера: {}", cluster.name);
  133. return Ok((cluster.clone(), nccl));
  134. }
  135. }
  136. anyhow::bail!("Не удалось подобрать подходящий кластер");
  137. }
  138. /// Looks up a cluster by name without requiring NCCL config.
  139. pub fn select_cluster_by_name(config: &Config, cluster_name: &str) -> Result<Cluster> {
  140. let cluster = config
  141. .clusters
  142. .iter()
  143. .find(|c| c.name == cluster_name)
  144. .context(format!("Кластер {} не найден", cluster_name))?;
  145. Ok(cluster.clone())
  146. }
  147. /// Resolves the cluster for a recipe, applying recipe-level cluster pinning.
  148. pub fn resolve_cluster<'c>(
  149. config: &'c Config,
  150. recipe: &Recipe,
  151. cluster_name: &str,
  152. ) -> Result<(Cluster, &'c NcclConfig)> {
  153. let choice = if cluster_name != "auto" {
  154. cluster_name.to_string()
  155. } else if let Some(ref rc) = recipe.cluster {
  156. rc.clone()
  157. } else {
  158. "auto".to_string()
  159. };
  160. select_cluster(config, &choice)
  161. }
  162. /// Loads a recipe from disk.
  163. pub async fn load_recipe(recipe_name: &str) -> Result<Recipe> {
  164. let path = format!("recipes/{}/recipe.toml", recipe_name);
  165. let content = tokio::fs::read_to_string(&path).await?;
  166. Ok(toml::from_str(&content)?)
  167. }
  168. /// Returns the cluster and recipe name for a recipe (for stop/logs commands
  169. /// that only receive a recipe name).
  170. pub async fn resolve_cluster_for_recipe(
  171. config: &Config,
  172. recipe_name: &str,
  173. ) -> Result<(Cluster, Recipe)> {
  174. let recipe = load_recipe(recipe_name).await?;
  175. let (cluster, _) = resolve_cluster(config, &recipe, "auto")?;
  176. Ok((cluster, recipe))
  177. }
  178. #[cfg(test)]
  179. mod tests {
  180. use super::*;
  181. fn test_cluster() -> Cluster {
  182. Cluster {
  183. name: "test-cluster".to_string(),
  184. master_ib_ip: "192.168.1.1".to_string(),
  185. nodes: vec![
  186. Node {
  187. name: "node0".to_string(),
  188. ssh_ip: "10.0.0.1".to_string(),
  189. ib_ip: "192.168.1.1".to_string(),
  190. rank: 0,
  191. host_key: None,
  192. },
  193. Node {
  194. name: "node1".to_string(),
  195. ssh_ip: "10.0.0.2".to_string(),
  196. ib_ip: "192.168.1.2".to_string(),
  197. rank: 1,
  198. host_key: None,
  199. },
  200. ],
  201. }
  202. }
  203. fn test_nccl() -> NcclConfig {
  204. NcclConfig {
  205. ib_hca: "rocep1s0f0".to_string(),
  206. socket_ifname: "enp1s0f0np0".to_string(),
  207. ib_gid_index: 3,
  208. ib_disable: 0,
  209. }
  210. }
  211. // ── Deserialization tests ──────────────────────────────────
  212. #[test]
  213. fn test_recipe_deserialize_minimal() {
  214. let toml_str = r#"
  215. name = "Minimal"
  216. model = "test/model"
  217. "#;
  218. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  219. assert_eq!(recipe.name, "Minimal");
  220. assert_eq!(recipe.model, "test/model");
  221. assert_eq!(recipe.engine, "vllm");
  222. assert_eq!(recipe.port, 0);
  223. assert!(recipe.args.is_empty());
  224. assert!(recipe.env.is_empty());
  225. assert_eq!(recipe.distributed_backend, "mp");
  226. assert_eq!(recipe.tensor_parallel_size, 1);
  227. }
  228. #[test]
  229. fn test_recipe_deserialize_full() {
  230. let toml_str = r#"
  231. engine = "sglang"
  232. name = "Full Recipe"
  233. docker_image = "org/image:tag"
  234. model = "org/model"
  235. port = 30000
  236. required_nodes = 4
  237. pipeline_parallel_size = 2
  238. distributed_backend = "ray"
  239. cluster = "my-cluster"
  240. args = ["--arg1", "val1", "--arg2"]
  241. [env]
  242. KEY = "value"
  243. OTHER = "123"
  244. "#;
  245. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  246. assert_eq!(recipe.engine, "sglang");
  247. assert_eq!(recipe.name, "Full Recipe");
  248. assert_eq!(recipe.docker_image, "org/image:tag");
  249. assert_eq!(recipe.model, "org/model");
  250. assert_eq!(recipe.port, 30000);
  251. assert_eq!(recipe.required_nodes, Some(4));
  252. assert_eq!(recipe.pipeline_parallel_size, Some(2));
  253. assert_eq!(recipe.tensor_parallel_size, 1);
  254. assert_eq!(recipe.distributed_backend, "ray");
  255. assert_eq!(recipe.cluster, Some("my-cluster".to_string()));
  256. assert_eq!(recipe.args, vec!["--arg1", "val1", "--arg2"]);
  257. assert_eq!(recipe.env.get("KEY").unwrap(), "value");
  258. assert_eq!(recipe.env.get("OTHER").unwrap(), "123");
  259. }
  260. #[test]
  261. fn test_recipe_deserialize_no_required_nodes() {
  262. let toml_str = r#"
  263. name = "Flexible"
  264. model = "test/model"
  265. "#;
  266. let recipe: Recipe = toml::from_str(toml_str).unwrap();
  267. assert_eq!(recipe.required_nodes, None);
  268. assert_eq!(recipe.pipeline_parallel_size, None);
  269. }
  270. #[test]
  271. fn test_config_deserialize() {
  272. let toml_str = r#"
  273. ssh_user = "admin"
  274. ssh_key_path = "/home/admin/.ssh/id_rsa"
  275. [[clusters]]
  276. name = "prod"
  277. master_ib_ip = "10.0.0.1"
  278. nodes = [
  279. { name = "node1", ssh_ip = "10.0.0.10", ib_ip = "10.0.0.1", rank = 0 },
  280. { name = "node2", ssh_ip = "10.0.0.11", ib_ip = "10.0.0.2", rank = 1 },
  281. ]
  282. [nccl."prod"]
  283. ib_hca = "mlx5_0"
  284. socket_ifname = "eth0"
  285. ib_gid_index = 3
  286. ib_disable = 0
  287. "#;
  288. let config: Config = toml::from_str(toml_str).unwrap();
  289. assert_eq!(config.ssh_user, "admin");
  290. assert_eq!(config.clusters.len(), 1);
  291. assert_eq!(config.clusters[0].nodes.len(), 2);
  292. assert_eq!(config.clusters[0].nodes[0].host_key, None);
  293. assert!(config.nccl.contains_key("prod"));
  294. assert_eq!(config.nccl["prod"].ib_hca, "mlx5_0");
  295. }
  296. #[test]
  297. fn test_config_deserialize_with_host_key() {
  298. let toml_str = r#"
  299. ssh_user = "admin"
  300. ssh_key_path = "/path/to/key"
  301. [[clusters]]
  302. name = "secure"
  303. master_ib_ip = "1.1.1.1"
  304. nodes = [
  305. { name = "n1", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0, host_key = "SHA256:abc123" },
  306. ]
  307. "#;
  308. let config: Config = toml::from_str(toml_str).unwrap();
  309. assert_eq!(
  310. config.clusters[0].nodes[0].host_key,
  311. Some("SHA256:abc123".to_string())
  312. );
  313. }
  314. // ── select_cluster tests ───────────────────────────────────
  315. #[test]
  316. fn test_select_cluster_by_name() {
  317. let mut config = Config {
  318. ssh_user: "user".to_string(),
  319. ssh_key_path: "/path/to/key".to_string(),
  320. clusters: vec![test_cluster()],
  321. nccl: HashMap::new(),
  322. hf_cache_dir: None,
  323. init_port: 25000,
  324. before_serve: HashMap::new(),
  325. exec_aliases: HashMap::new(),
  326. };
  327. config.nccl.insert("test-cluster".to_string(), test_nccl());
  328. let (cluster, nccl) = select_cluster(&config, "test-cluster").unwrap();
  329. assert_eq!(cluster.name, "test-cluster");
  330. assert_eq!(nccl.ib_hca, "rocep1s0f0");
  331. }
  332. #[test]
  333. fn test_select_cluster_auto() {
  334. let mut config = Config {
  335. ssh_user: "user".to_string(),
  336. ssh_key_path: "/path/to/key".to_string(),
  337. clusters: vec![test_cluster()],
  338. nccl: HashMap::new(),
  339. hf_cache_dir: None,
  340. init_port: 25000,
  341. before_serve: HashMap::new(),
  342. exec_aliases: HashMap::new(),
  343. };
  344. config.nccl.insert("test-cluster".to_string(), test_nccl());
  345. let (cluster, nccl) = select_cluster(&config, "auto").unwrap();
  346. assert_eq!(cluster.name, "test-cluster");
  347. assert_eq!(nccl.ib_hca, "rocep1s0f0");
  348. }
  349. #[test]
  350. fn test_select_cluster_auto_no_match() {
  351. let config = Config {
  352. ssh_user: "user".to_string(),
  353. ssh_key_path: "/path/to/key".to_string(),
  354. clusters: vec![test_cluster()],
  355. nccl: HashMap::new(),
  356. hf_cache_dir: None,
  357. init_port: 25000,
  358. before_serve: HashMap::new(),
  359. exec_aliases: HashMap::new(),
  360. };
  361. let result = select_cluster(&config, "auto");
  362. assert!(result.is_err());
  363. }
  364. #[test]
  365. fn test_select_cluster_not_found() {
  366. let config = Config {
  367. ssh_user: "user".to_string(),
  368. ssh_key_path: "/path/to/key".to_string(),
  369. clusters: vec![],
  370. nccl: HashMap::new(),
  371. hf_cache_dir: None,
  372. init_port: 25000,
  373. before_serve: HashMap::new(),
  374. exec_aliases: HashMap::new(),
  375. };
  376. let result = select_cluster(&config, "nonexistent");
  377. assert!(result.is_err());
  378. }
  379. #[test]
  380. fn test_select_cluster_by_name_found() {
  381. let mut nccl_map = HashMap::new();
  382. nccl_map.insert("test-cluster".to_string(), test_nccl());
  383. let config = Config {
  384. ssh_user: "u".to_string(),
  385. ssh_key_path: "/k".to_string(),
  386. clusters: vec![test_cluster()],
  387. nccl: nccl_map,
  388. hf_cache_dir: None,
  389. init_port: 25000,
  390. before_serve: HashMap::new(),
  391. exec_aliases: HashMap::new(),
  392. };
  393. let cluster = select_cluster_by_name(&config, "test-cluster").unwrap();
  394. assert_eq!(cluster.name, "test-cluster");
  395. assert_eq!(cluster.nodes.len(), 2);
  396. }
  397. #[test]
  398. fn test_select_cluster_by_name_not_found() {
  399. let config = Config {
  400. ssh_user: "u".to_string(),
  401. ssh_key_path: "/k".to_string(),
  402. clusters: vec![test_cluster()],
  403. nccl: HashMap::new(),
  404. hf_cache_dir: None,
  405. init_port: 25000,
  406. before_serve: HashMap::new(),
  407. exec_aliases: HashMap::new(),
  408. };
  409. let result = select_cluster_by_name(&config, "no-such-cluster");
  410. assert!(result.is_err());
  411. }
  412. // ── Defaults ───────────────────────────────────────────────
  413. #[test]
  414. fn test_default_engine_is_vllm() {
  415. let recipe: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"").unwrap();
  416. assert_eq!(recipe.engine, "vllm");
  417. }
  418. #[test]
  419. fn test_default_distributed_backend_is_mp() {
  420. let recipe: Recipe = toml::from_str("name = \"x\"\nmodel = \"m\"").unwrap();
  421. assert_eq!(recipe.distributed_backend, "mp");
  422. }
  423. }