Pārlūkot izejas kodu

serve --cold: recipe-declared cold_args overlay for first-launch safety

A recipe may declare 'cold_args' — a flat list of engine flags in the same
shape as 'args' (engine-agnostic: vLLM writes --gpu-memory-utilization,
SGLang writes --mem-fraction-static, ...). 'serve --cold' overlays them onto
args by flag name: any flag in cold_args replaces its value in args (or is
appended if absent); untouched flags stay. This is the first-launch /
tight-node safety valve for memory knobs (GMU, KV/context limits) — boot it
conservatively, then drop the flag once the lane is proven — without editing
the recipe per launch. Today's Ling KV-OOM and GLM unified-memory stalls both
needed exactly this (I edited recipes by hand each time).

New pure crate::cold::apply_cold + 5 tests (space/= forms, boolean flags,
append-absent, engine-agnostic). Recipe.cold_args field, ServeOpts.cold,
--cold CLI flag wired through serve; applied in docker::serve right after
load_recipe so cluster-select, command build and dry-run all see the
effective args. i18n cold-applied / cold-no-block in all 6 locales.
Alexander Korolev 6 dienas atpakaļ
vecāks
revīzija
8f78bcdc45

+ 2 - 0
locales/de/main.ftl

@@ -90,6 +90,8 @@ host-key-line = { $name } ({ $ip }) -> host_key = { $key }
 host-key-error = { $name } ({ $ip }) -> Fehler: { $error }
 host-key-no-match = (keine Knoten matched)
 recipe-starting = 🚀 Rezept wird gestartet: { $name } ({ $engine }) [Backend: { $backend }]
+cold-applied = ❄ --cold: die cold_args des Rezepts über seine args gelegt
+cold-no-block = ❄ --cold: Rezept '{ $name }' hat keine cold_args; Start mit normalen args
 cluster-switch-off-detected = ⚠️ Cluster-Schalter scheint aus zu sein
 cluster-switch-powered-on = ✅ Cluster-Schalter eingeschaltet
 cluster-waiting-for-nodes = ⏳ Warte, bis Knoten verfügbar sind...

+ 2 - 0
locales/en/main.ftl

@@ -81,6 +81,8 @@ host-key-line = { $name } ({ $ip }) -> host_key = { $key }
 host-key-error = { $name } ({ $ip }) -> error: { $error }
 host-key-no-match = (no nodes matched)
 recipe-starting = 🚀 Starting recipe: { $name } ({ $engine }) [backend: { $backend }]
+cold-applied = ❄ --cold: applied the recipe's cold_args over its args
+cold-no-block = ❄ --cold: recipe '{ $name }' has no cold_args; serving with normal args
 cluster-switch-off-detected = ⚠️ Cluster switch appears to be off
 cluster-switch-powered-on = ✅ Cluster switch powered on
 cluster-waiting-for-nodes = ⏳ Waiting for nodes to become available...

+ 2 - 0
locales/es/main.ftl

@@ -90,6 +90,8 @@ host-key-line = { $name } ({ $ip }) -> host_key = { $key }
 host-key-error = { $name } ({ $ip }) -> error: { $error }
 host-key-no-match = (ningún nodo coincidió)
 recipe-starting = 🚀 Iniciando receta: { $name } ({ $engine }) [backend: { $backend }]
+cold-applied = ❄ --cold: aplicados los cold_args de la receta sobre sus args
+cold-no-block = ❄ --cold: la receta '{ $name }' no tiene cold_args; se sirve con args normales
 cluster-switch-off-detected = ⚠️ El switch del cluster parece estar apagado
 cluster-switch-powered-on = ✅ Switch del cluster encendido
 cluster-waiting-for-nodes = ⏳ Esperando que los nodos estén disponibles...

+ 2 - 0
locales/fr/main.ftl

@@ -90,6 +90,8 @@ host-key-line = { $name } ({ $ip }) -> host_key = { $key }
 host-key-error = { $name } ({ $ip }) -> erreur : { $error }
 host-key-no-match = (aucun nœud correspondant)
 recipe-starting = 🚀 Démarrage de la recette : { $name } ({ $engine }) [backend : { $backend }]
+cold-applied = ❄ --cold : application des cold_args de la recette par-dessus ses args
+cold-no-block = ❄ --cold : la recette '{ $name }' n'a pas de cold_args ; service avec les args normaux
 cluster-switch-off-detected = ⚠️ L'interrupteur du cluster semble éteint
 cluster-switch-powered-on = ✅ Interrupteur du cluster allumé
 cluster-waiting-for-nodes = ⏳ En attente de la disponibilité des nœuds...

+ 2 - 0
locales/it/main.ftl

@@ -90,6 +90,8 @@ host-key-line = { $name } ({ $ip }) -> host_key = { $key }
 host-key-error = { $name } ({ $ip }) -> errore: { $error }
 host-key-no-match = (nessun nodo corrisponde)
 recipe-starting = 🚀 Avvio ricetta: { $name } ({ $engine }) [backend: { $backend }]
+cold-applied = ❄ --cold: applicati i cold_args della ricetta sopra i suoi args
+cold-no-block = ❄ --cold: la ricetta '{ $name }' non ha cold_args; servizio con args normali
 cluster-switch-off-detected = ⚠️ Lo switch del cluster sembra spento
 cluster-switch-powered-on = ✅ Switch del cluster acceso
 cluster-waiting-for-nodes = ⏳ In attesa che i nodi siano disponibili...

+ 2 - 0
locales/ru/main.ftl

@@ -91,6 +91,8 @@ host-key-line = { $name } ({ $ip }) -> host_key = { $key }
 host-key-error = { $name } ({ $ip }) -> ошибка: { $error }
 host-key-no-match = (нет подходящих узлов)
 recipe-starting = 🚀 Запуск рецепта: { $name } ({ $engine }) [backend: { $backend }]
+cold-applied = ❄ --cold: применён cold_args рецепта поверх его args
+cold-no-block = ❄ --cold: у рецепта '{ $name }' нет cold_args; запуск с обычными args
 cluster-switch-off-detected = ⚠️ Похоже, переключатель кластера выключен
 cluster-switch-powered-on = ✅ Переключатель кластера включен
 cluster-waiting-for-nodes = ⏳ Ожидание доступности узлов...

+ 1 - 0
src/after_stop.rs

@@ -201,6 +201,7 @@ mod tests {
             pipeline_parallel_size: None,
             tensor_parallel_size: 1,
             args: crate::pre_serve::RecipeArgs::default(),
+            cold_args: crate::pre_serve::RecipeArgs::default(),
             env: std::collections::HashMap::new(),
             cluster: None,
             entrypoint: vec![],

+ 1 - 0
src/before_serve.rs

@@ -369,6 +369,7 @@ mod tests {
             pipeline_parallel_size: None,
             tensor_parallel_size: 1,
             args: crate::pre_serve::RecipeArgs::default(),
+            cold_args: crate::pre_serve::RecipeArgs::default(),
             env: HashMap::new(),
             cluster: None,
             entrypoint: vec![],

+ 7 - 0
src/cli.rs

@@ -113,6 +113,13 @@ pub enum Commands {
         #[arg(long, num_args = 0..=1, default_missing_value = "")]
         fit: Option<String>,
 
+        /// Apply the recipe's `cold_args` over its `args` (overlay by flag
+        /// name) — the first-launch / tight-node safety valve for memory
+        /// knobs (GMU, KV/context limits). No-op if the recipe has no
+        /// `cold_args`.
+        #[arg(long)]
+        cold: bool,
+
         /// `sudo rm -rf` the host-side extra_mounts folders on the launch
         /// nodes before containers start (repo-based mounts untouched).
         #[arg(long)]

+ 186 - 0
src/cold.rs

@@ -0,0 +1,186 @@
+//! Cold-start overrides.
+//!
+//! A recipe may declare `cold_args` — a flat list of engine flags in exactly
+//! the same shape as the main `args` (so it is engine-agnostic: a vLLM recipe
+//! writes `--gpu-memory-utilization 0.75 --max-model-len 32768`, an SGLang
+//! recipe writes `--mem-fraction-static 0.6 …`). `serve --cold` overlays those
+//! onto the main args: any flag that appears in `cold_args` **replaces** its
+//! value in the main args (or is appended if the recipe omitted it). Flags not
+//! mentioned in `cold_args` are left untouched.
+//!
+//! This is the "boot it safely first, then serve full" mode: the first launch
+//! (or a launch onto a tight node) uses the conservative `cold_args` to avoid a
+//! KV-cache OOM or a GB10 unified-memory *stall*, without editing the recipe
+//! per launch. The values are taken as written — the recipe author is expected
+//! to write cold values lower than the serving values.
+
+/// Overlay `cold` onto `main`: for each flag in `cold`, replace that flag's
+/// value in `main` (or append `flag value` when `main` lacks it). Handles the
+/// space form (`--max-len 32768`), the `=` form (`--max-len=32768`), and
+/// boolean flags (a flag with no following value is just ensured present).
+/// Returns a new vec; `main` is unchanged.
+pub fn apply_cold(main: &[String], cold: &[String]) -> Vec<String> {
+    let mut out = main.to_vec();
+    let mut i = 0;
+    while i < cold.len() {
+        let tok = cold[i].as_str();
+        if !tok.starts_with('-') {
+            // A stray value token with no preceding flag — skip defensively.
+            i += 1;
+            continue;
+        }
+        if let Some(eq) = tok.find('=') {
+            // `--flag=value` form.
+            set_flag(&mut out, &tok[..eq], tok[eq + 1..].to_string());
+            i += 1;
+            continue;
+        }
+        // Space form: the value is the next token iff it exists and is not
+        // itself a flag.
+        let has_value = i + 1 < cold.len() && !cold[i + 1].starts_with('-');
+        if has_value {
+            set_flag(&mut out, tok, cold[i + 1].clone());
+            i += 2;
+        } else {
+            // Boolean flag: ensure it is present in `main`.
+            if !out.iter().any(|a| a == tok) {
+                out.push(tok.to_string());
+            }
+            i += 1;
+        }
+    }
+    out
+}
+
+/// Replace the value of `flag` in `args`, or append `flag value` when absent.
+/// Handles both the space form (`--max-len 32768`) and the `=` form
+/// (`--max-len=32768`).
+fn set_flag(args: &mut Vec<String>, flag: &str, value: String) {
+    let eq = format!("{flag}=");
+    if let Some(i) = args.iter().position(|a| a == flag || a.starts_with(&eq)) {
+        if args[i] == flag {
+            // Space form: the value is the next token.
+            if i + 1 < args.len() {
+                args[i + 1] = value;
+            } else {
+                args.push(value);
+            }
+        } else {
+            // `--flag=value` form: rewrite the whole token.
+            args[i] = format!("{flag}={value}");
+        }
+    } else {
+        args.push(flag.to_string());
+        args.push(value);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn main_args() -> Vec<String> {
+        [
+            "--served-model-name",
+            "m",
+            "--max-model-len",
+            "500000",
+            "--max-num-seqs",
+            "4",
+            "--gpu-memory-utilization",
+            "0.87",
+        ]
+        .iter()
+        .map(|s| s.to_string())
+        .collect()
+    }
+
+    fn get(args: &[String], flag: &str) -> String {
+        args.iter()
+            .position(|a| a == flag)
+            .map(|i| args[i + 1].clone())
+            .unwrap_or_default()
+    }
+
+    #[test]
+    fn cold_replaces_present_flags_and_appends_absent() {
+        let cold = [
+            "--gpu-memory-utilization",
+            "0.75",
+            "--max-model-len",
+            "32768",
+            "--max-num-seqs",
+            "2",
+            "--max-num-batched-tokens",
+            "2048",
+        ]
+        .iter()
+        .map(|s| s.to_string())
+        .collect::<Vec<_>>();
+        let out = apply_cold(&main_args(), &cold);
+        // Each flag appears exactly once (replaced, not duplicated).
+        for f in [
+            "--max-model-len",
+            "--max-num-seqs",
+            "--gpu-memory-utilization",
+        ] {
+            assert_eq!(
+                out.iter().filter(|a| a.as_str() == f).count(),
+                1,
+                "{f} duplicated"
+            );
+        }
+        assert_eq!(get(&out, "--max-model-len"), "32768");
+        assert_eq!(get(&out, "--max-num-seqs"), "2");
+        assert_eq!(get(&out, "--gpu-memory-utilization"), "0.75");
+        // Absent flag appended.
+        assert_eq!(get(&out, "--max-num-batched-tokens"), "2048");
+        // Untouched flags stay.
+        assert_eq!(get(&out, "--served-model-name"), "m");
+    }
+
+    #[test]
+    fn cold_handles_eq_form_and_appends() {
+        let base = vec!["--max-model-len=500000".to_string()];
+        let cold = ["--max-model-len=16384", "--max-num-seqs", "1"]
+            .iter()
+            .map(|s| s.to_string())
+            .collect::<Vec<_>>();
+        let out = apply_cold(&base, &cold);
+        assert_eq!(out, vec!["--max-model-len=16384", "--max-num-seqs", "1"]);
+    }
+
+    #[test]
+    fn cold_boolean_flag_ensured_present_once() {
+        let base = vec!["--enable-prefix-caching".to_string(), "--foo".to_string()];
+        let cold = ["--enable-prefix-caching", "--bar"]
+            .iter()
+            .map(|s| s.to_string())
+            .collect::<Vec<_>>();
+        let out = apply_cold(&base, &cold);
+        assert_eq!(
+            out.iter()
+                .filter(|a| a.as_str() == "--enable-prefix-caching")
+                .count(),
+            1
+        );
+        assert!(out.iter().any(|a| a.as_str() == "--bar"));
+    }
+
+    #[test]
+    fn empty_cold_is_noop() {
+        assert_eq!(apply_cold(&main_args(), &[]), main_args());
+    }
+
+    #[test]
+    fn engine_agnostic_sglang_flags() {
+        // A cold block is not tied to vLLM flag names — any flag overlays.
+        let base = vec!["--mem-fraction-static".to_string(), "0.9".to_string()];
+        let cold = ["--mem-fraction-static", "0.6"]
+            .iter()
+            .map(|s| s.to_string())
+            .collect::<Vec<_>>();
+        let out = apply_cold(&base, &cold);
+        assert_eq!(get(&out, "--mem-fraction-static"), "0.6");
+    }
+}

+ 4 - 0
src/config/recipe.rs

@@ -82,6 +82,10 @@ pub struct Recipe {
     pub tensor_parallel_size: u32,
     #[serde(default)]
     pub args: RecipeArgs,
+    /// Conservative engine flags applied only under `serve --cold` (overlay
+    /// onto `args` by flag name). Same shape as `args`, engine-agnostic.
+    #[serde(default)]
+    pub cold_args: RecipeArgs,
     #[serde(default)]
     pub env: HashMap<String, EnvValue>,
     #[serde(default)]

+ 1 - 0
src/docker/build.rs

@@ -412,6 +412,7 @@ pub async fn handle_build_cmd(config: &Config, args: &BuildCmdArgs, dry_run: boo
         hf_cache: None,
         stage: None,
         fit: None,
+        cold: false,
         clear: false,
     };
     handle_build(

+ 1 - 0
src/docker/command_tests.rs

@@ -93,6 +93,7 @@ fn base_recipe() -> Recipe {
         pipeline_parallel_size: Some(2),
         tensor_parallel_size: 1,
         args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
+        cold_args: RecipeArgs::default(),
         env: HashMap::new(),
         cluster: None,
         entrypoint: vec![],

+ 1 - 0
src/docker/mirror.rs

@@ -191,6 +191,7 @@ mod tests {
             pipeline_parallel_size: Some(1),
             tensor_parallel_size: 1,
             args: RecipeArgs::List(vec![]),
+            cold_args: RecipeArgs::default(),
             env: HashMap::new(),
             cluster: None,
             entrypoint: vec![],

+ 25 - 0
src/docker/mod.rs

@@ -77,6 +77,9 @@ pub struct ServeOpts {
     /// the whole selected cluster; `Some("N")` = scale to N nodes;
     /// `None` = unset. See `docker::serve`.
     pub fit: Option<String>,
+    /// Apply the recipe's `cold_args` over `args` (`--cold`): the first-launch
+    /// safety valve for memory-tight nodes. See `crate::cold`.
+    pub cold: bool,
     pub clear: bool,
 }
 /// Shell-quote for remote `sh -c` (single-quote wrapper, escapes quotes).
@@ -102,6 +105,28 @@ pub async fn serve(
         "{}",
         fluent_i18n::t!("recipe-starting", { "name" => rn, "engine" => re, "backend" => recipe.distributed_backend.map_or("none".to_string(), |b| b.to_string()) })
     );
+    // `--cold`: overlay the recipe's conservative `cold_args` onto `args` by
+    // flag name — the first-launch / tight-node safety valve (crate::cold).
+    // Applied here so every downstream consumer (cluster select, command
+    // build, dry-run print) sees the effective cold args.
+    if opts.cold {
+        let cold = recipe.cold_args.to_vec();
+        if cold.is_empty() {
+            warn!(
+                "{}",
+                fluent_i18n::t!("cold-no-block", { "name" => recipe.name.clone() })
+            );
+        } else {
+            recipe.args = crate::pre_serve::RecipeArgs::List(crate::cold::apply_cold(
+                &recipe.args.to_vec(),
+                &cold,
+            ));
+            info!(
+                "{}",
+                fluent_i18n::t!("cold-applied", { "name" => recipe.name.clone() })
+            );
+        }
+    }
     // `--fit`: override required_nodes + tensor_parallel_size so TP tracks
     // the node count. Bare `--fit` (whole cluster) is handled after cluster
     // selection; for now clear the cap so selection keeps every node.

+ 1 - 0
src/doctor.rs

@@ -364,6 +364,7 @@ mod tests {
                 "--max-model-len".to_string(),
                 "4096".to_string(),
             ]),
+            cold_args: crate::pre_serve::RecipeArgs::default(),
             env: HashMap::new(),
             cluster: None,
             entrypoint: vec![],

+ 1 - 0
src/engine.rs

@@ -298,6 +298,7 @@ pub(crate) mod tests {
             pipeline_parallel_size: Some(1),
             tensor_parallel_size: 1,
             args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
+            cold_args: RecipeArgs::default(),
             env: HashMap::new(),
             cluster: None,
             entrypoint: vec!["vllm".into(), "serve".into()],

+ 3 - 0
src/main.rs

@@ -30,6 +30,7 @@ mod builtin_scripts;
 mod cli;
 mod cli_sub;
 mod cluster_state;
+mod cold;
 mod config;
 mod detect;
 mod docker;
@@ -136,6 +137,7 @@ async fn main() -> Result<()> {
             hf_cache,
             stage,
             fit,
+            cold,
             clear,
             no_push,
             image_source,
@@ -163,6 +165,7 @@ async fn main() -> Result<()> {
                     hf_cache,
                     stage,
                     fit,
+                    cold,
                     clear,
                 },
                 !no_follow,

+ 1 - 0
src/staging.rs

@@ -249,6 +249,7 @@ mod tests {
             pipeline_parallel_size: Some(1),
             tensor_parallel_size: 1,
             args: RecipeArgs::List(vec![]),
+            cold_args: RecipeArgs::default(),
             env: HashMap::new(),
             cluster: None,
             entrypoint: vec![],

+ 1 - 0
src/tmux/commands.rs

@@ -155,6 +155,7 @@ mod tests {
             pipeline_parallel_size: Some(1),
             tensor_parallel_size: 1,
             args: RecipeArgs::List(vec!["--x".into(), "4096".into()]),
+            cold_args: RecipeArgs::default(),
             env: HashMap::new(),
             cluster: None,
             entrypoint: vec!["vllm".into(), "serve".into()],

+ 1 - 0
src/web/api.rs

@@ -78,6 +78,7 @@ pub(crate) async fn serve(
         hf_cache: None,
         stage: None,
         fit: req.fit,
+        cold: false,
         clear: req.clear,
     };
     let recipe = req.recipe.clone();