diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md index 80d8a28..057f3b6 100644 --- a/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md @@ -173,6 +173,87 @@ Côté Go, `TestFacadeTune` vérifie : - le contexte : au repos, pendant l'optimisation, pendant un bench ; - le démarrage et l'arrêt sans le refus de l'optimiseur. +## Mutations + +105 mutations du port Rust (verrou et job). Le premier passage en a tué 39. +Les survivantes l'ont été ensuite par les tests ajoutés : + +- `tune/tests.rs` : + - un verrou sans PID ; + - l'identité d'un processus (démarrage noté, binaire admis) ; + - l'application planifiée : clé sur option refusée sans l'option, valeur + vide qui retire la clé ; +- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ; +- `tests/tune.rs` : + - **une optimisation entière** : + - l'essai noté dans le verrou pendant la mesure ; + - l'application refusée pendant la mesure ; + - la progression, les confirmations, le gain ; + - aucun changement de moteur signalé quand son build est inconnu ; + - la cible inconnue ; + - **l'application gardée** : + - preset qui n'est plus celui en service, API externe ; + - clé changée ailleurs pendant l'application (défaite) ; + - sauvegarde notée dans le verrou avant d'écrire ; + - **les essais** : + - écartés (ligne dénaturée, doublon, contexte perdu) ; + - muets (délai de chargement) ; + - **la mesure** : + - VRAM mesurée par un faux nvidia-smi ; + - budget atteint, profondeur commune ; + - notes et poids tirés de la télémétrie ; + - écarts de mesure (sous le seuil, nul, decode plus lent) ; + - **le moteur** : + - `/props` sans `default_generation_settings` ; + - SIDE_SLOT sur un seul slot ; + - threads comptés sur les cœurs de la sonde ; + - piloté par la façade (LOKI_ENGINE=rust) ; + - **les refus** : + - un second lancement pendant la prise du bail ; + - une application refusée sans journal ; + - hors phase d'application, un verrou périmé n'est pas défait. + +Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))` +garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle, +et tuée. + +Restent vingt équivalentes, ou hors de portée d'un test : + +- **le verrou** : + - le fichier retiré quand son écriture échoue après sa création (disque + plein), que les tests ne provoquent pas ; + - deux re-vérifications du ménage, contre un propriétaire qui revient + entre deux lectures : courses seulement ; + - la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le + retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte + que si le retrait échoue ; +- **le suivi** : + - le résultat montré hors mesure : il est effacé au départ d'une mesure ; + - l'annulation n'est posée que pendant une mesure ; + - la borne de 6 h du budget, la durée d'un doublon, les étapes restantes : + l'estimation seulement ; + - l'annulation vérifiée entre deux essais et à la fin : l'essai en cours + l'a déjà dite (course) ; +- **la mesure** : + - VRAM nulle et nvidia-smi absent vont ensemble ; + - les candidats générés passent toujours `cfg_check` (garde contre une + génération fautive) ; + - la fin d'un essai constatée avant d'attendre : un réveil perdu, quand + l'essai meurt entre deux attentes (course) ; + - l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le + verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant + tout arrêt ; +- **l'application** : + - une valeur vide gardée dans la configuration gagnante : `cfg_check` et la + réécriture la traitent en clé absente ; + - la configuration active comparée à celle de la mesure : les empreintes + du preset et de la configuration sont prises ensemble, sur le preset dont + l'empreinte est celle de la configuration ; les deux contrôles qui + précèdent (preset inchangé, preset en service) l'impliquent ; + - le preset « non appliqué » : la configuration a changé entre le contrôle + et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre + message. + ## Écarts assumés - Une sortie à blanc qui échoue est lue dans un fichier où vont les deux diff --git a/rust/loki-rs/src/tune/job.rs b/rust/loki-rs/src/tune/job.rs index df7bd36..016f891 100644 --- a/rust/loki-rs/src/tune/job.rs +++ b/rust/loki-rs/src/tune/job.rs @@ -304,6 +304,12 @@ pub struct Tune { pub trial_env: Vec<(String, String)>, /// Durée du bail du moteur (renouvelé au tiers). pub lease_ttl: Duration, + /// nvidia-smi (un faux dans les tests). + pub nvidia_smi: String, + /// Entre deux lectures du bilan de VRAM (500 ms). + pub gpu_pause: Duration, + /// Unité de `budget_min` (une minute ; plus court dans les tests). + pub budget_unit: Duration, } impl Tune { @@ -328,9 +334,33 @@ impl Tune { settle: Duration::from_secs(2), trial_env: Vec::new(), lease_ttl: LEASE_TTL, + nvidia_smi: "nvidia-smi".into(), + gpu_pause: Duration::from_millis(500), + budget_unit: Duration::from_secs(60), } } + /// `gpuStats` : lecture FRAÎCHE, vide sans nvidia-smi. + async fn gpus(&self) -> Vec { + crate::sysinfo::nvidia_smi_query_bin(&self.nvidia_smi) + .await + .map(|o| crate::sysinfo::parse_nvidia_smi(&String::from_utf8_lossy(&o))) + .unwrap_or_default() + } + + /// `gpuUsedSettled`. + async fn gpu_settled(&self, before: i64) -> i64 { + crate::enginectl::gpu_settle( + before, + || async { + let g = self.gpus().await; + (crate::enginectl::gpu_used_mb(&g), !g.is_empty()) + }, + self.gpu_pause, + ) + .await + } + fn st(&self) -> MutexGuard<'_, State> { self.state.lock().unwrap_or_else(|e| e.into_inner()) } @@ -789,7 +819,7 @@ impl Tune { }; if held.snapshot().main_was_active { tr.set_phase("arrêt du moteur principal"); - let gpus = crate::sysinfo::gpu_stats().await; + let gpus = self.gpus().await; let before = crate::enginectl::gpu_used_mb(&gpus); if let Err(e) = Self::svc_os(ctx, "stop").await { return Err(format!( @@ -799,7 +829,7 @@ impl Tune { } crate::enginectl::dictate_stop(ctx).await; if !gpus.is_empty() { - gpu_settled(before).await; + self.gpu_settled(before).await; } crate::sysinfo::GPU_MONITOR.invalidate().await; } @@ -824,7 +854,7 @@ impl Tune { .await .map_err(|e| format!("la configuration active ne se lance pas : {e}"))?; let probe = base_launch.probe.clone().unwrap_or_default(); - let smi = crate::sysinfo::gpu_stats().await; + let smi = self.gpus().await; let mut env = tc::Env { help: probe.help.clone(), gpus: probe.gpus, @@ -925,7 +955,8 @@ impl Tune { if !env.smi { return (-1, false); } - let g: Vec<(i64, i64)> = crate::sysinfo::gpu_stats() + let g: Vec<(i64, i64)> = self + .gpus() .await .iter() .map(|g| (g.total, g.used)) @@ -1621,7 +1652,7 @@ struct Loaded { struct Stopper<'a> { run: Option<(i64, Arc)>, held: &'a Held<'a>, - settle: Duration, + tune: &'a Tune, } impl Stopper<'_> { @@ -1629,7 +1660,7 @@ impl Stopper<'_> { let Some((pid, done)) = self.run.take() else { return; }; - let before = crate::enginectl::gpu_used_mb(&crate::sysinfo::gpu_stats().await); + let before = crate::enginectl::gpu_used_mb(&self.tune.gpus().await); let d = done.clone(); let _ = tokio::task::spawn_blocking(move || { lock::kill_tree(pid, &|| d.load(Ordering::SeqCst)); @@ -1643,9 +1674,9 @@ impl Stopper<'_> { eprintln!("[loki tune] l'essai (PID {pid}) ne s'est pas arrêté dans les temps"); } if before > 0 { - gpu_settled(before).await; + self.tune.gpu_settled(before).await; } else { - tokio::time::sleep(self.settle).await; + tokio::time::sleep(self.tune.settle).await; } } } @@ -1744,7 +1775,7 @@ impl<'a> Trialer<'a> { let mut stopper = Stopper { run: None, held: self.held, - settle: self.tune.settle, + tune: self.tune, }; let log_path = dir.join("engine.log"); let (out, err) = @@ -1998,19 +2029,6 @@ fn phys_cores(probe: &tc::Probe) -> i64 { crate::serve::threads::physical_cores(root, &online, &atom).unwrap_or(0) } -/// `gpuUsedSettled`. -async fn gpu_settled(before: i64) -> i64 { - crate::enginectl::gpu_settle( - before, - || async { - let g = crate::sysinfo::gpu_stats().await; - (crate::enginectl::gpu_used_mb(&g), !g.is_empty()) - }, - Duration::from_millis(500), - ) - .await -} - /// La dernière ligne de la sortie, sans couleurs ; vide : `fallback`. fn last_line(path: &Path, fallback: &str) -> String { let out = std::fs::read(path) @@ -2173,7 +2191,7 @@ pub async fn serve( ..Default::default() }; if d.int("budget_min") > 0 { - opts.budget = Duration::from_secs(d.int("budget_min") as u64 * 60); + opts.budget = tune.budget_unit * d.int("budget_min").min(1 << 20) as u32; } let (code, out) = tune.start(ctx, opts).await; send_json(code, &out) @@ -2206,3 +2224,59 @@ pub async fn serve( ), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// L'ETA : inconnue (-1) tant qu'aucun essai n'est mesuré ; ensuite la + /// moyenne fois les essais restants, bornée par le budget qui reste, + /// jamais négative. + #[test] + fn eta_du_suivi() { + let tr = Tracker::new(); + tr.lock().budget = Duration::from_secs(600); + assert_eq!(tr.snapshot()["eta_sec"], json!(-1)); + assert_eq!(tr.snapshot()["trials"], Value::Null); + { + let mut st = tr.lock(); + st.avg_sec = 10.0; + st.left = 5; + } + assert_eq!(tr.snapshot()["eta_sec"], json!(50)); + tr.lock().left = 100; + let eta = tr.snapshot()["eta_sec"].as_f64().unwrap(); + assert!(eta > 590.0 && eta <= 600.0, "{eta}"); + tr.lock().started = Instant::now() - Duration::from_secs(700); + assert_eq!(tr.snapshot()["eta_sec"], json!(0)); + // Fin : le temps écoulé s'arrête. + let started = tr.lock().started; + tr.lock().ended = Some(started + Duration::from_secs(3)); + assert_eq!(tr.snapshot()["elapsed_sec"], json!(3)); + // Un essai remplacé à sa place, un nouveau ajouté. + let i = tr.put(None, Trial::default()); + tr.put( + Some(i), + Trial { + label: "a".into(), + ..Default::default() + }, + ); + tr.put(Some(9), Trial::default()); + let t = tr.snapshot()["trials"].clone(); + assert_eq!(t.as_array().unwrap().len(), 2); + assert_eq!(t[0]["label"], json!("a")); + } + + #[test] + fn arrondi_a_la_minute() { + let m = |s: u64| round_minute(Duration::from_secs(s)).as_secs(); + assert_eq!( + (m(29), m(30), m(89), m(90), m(3600)), + (0, 60, 60, 120, 3600) + ); + assert_eq!(build_number("b7000-x"), 7000); + assert_eq!(build_number("7000"), 0); + assert_eq!(build_number("b"), 0); + } +} diff --git a/rust/loki-rs/src/tune/lock.rs b/rust/loki-rs/src/tune/lock.rs index 6f50b87..50c57e8 100644 --- a/rust/loki-rs/src/tune/lock.rs +++ b/rust/loki-rs/src/tune/lock.rs @@ -213,7 +213,12 @@ pub fn pid_alive(_pid: i64) -> bool { /// `tuneProcAlive` : `p` désigne-t-il encore le MÊME processus ? pub fn proc_alive(p: &Proc) -> bool { - let Some((start, exe)) = proc_identity(p.pid) else { + same_proc(p, proc_identity(p.pid)) +} + +/// [`proc_alive`], l'identité lue (`None` : processus absent). +pub fn same_proc(p: &Proc, identity: Option<(String, String)>) -> bool { + let Some((start, exe)) = identity else { return false; }; if !p.start.is_empty() && start != p.start { @@ -251,6 +256,12 @@ pub fn self_proc() -> Proc { /// sondé pour un orphelin d'un ancien Loki). #[cfg(unix)] pub fn kill_tree(pid: i64, gone: &dyn Fn() -> bool) { + kill_tree_within(pid, gone, Duration::from_secs(10)); +} + +/// [`kill_tree`], `grace` laissé au SIGTERM. +#[cfg(unix)] +pub fn kill_tree_within(pid: i64, gone: &dyn Fn() -> bool, grace: Duration) { let Ok(p) = i32::try_from(pid) else { return; }; @@ -273,7 +284,7 @@ pub fn kill_tree(pid: i64, gone: &dyn Fn() -> bool) { libc::kill(p, libc::SIGTERM); } } - if wait(Duration::from_secs(10)) { + if wait(grace) { return; } // SAFETY: idem. diff --git a/rust/loki-rs/src/tune/tests.rs b/rust/loki-rs/src/tune/tests.rs index e63c4b1..326c568 100644 --- a/rust/loki-rs/src/tune/tests.rs +++ b/rust/loki-rs/src/tune/tests.rs @@ -72,11 +72,13 @@ async fn verrou() { drop(l); alive.store(true, Ordering::SeqCst); let l2 = lf.acquire("web", false, no_reap).await.unwrap(); - // Un verrou rendu par sa poignée n'emporte pas celui d'un autre. - let other = l2.snapshot(); - l2.release(); + // Un verrou d'un autre processus (même PID et même prise, autre + // démarrage) n'est jamais retiré par release. + let mut other = l2.snapshot(); + other.owner.start = "autre".into(); std::fs::write(&lf.path, serde_json::to_vec(&other).unwrap()).unwrap(); - assert!(lf.path.exists()); + l2.release(); + assert_eq!(read_owner(&lf.path), Some(other), "verrou d'autrui retiré"); } /// Un verrou périmé dans la place, et `acquire` qui le fait écarter. @@ -213,6 +215,45 @@ async fn menage_jamais_sur_un_verrou_vivant() { ); } +/// Un verrou sans PID n'en est pas un. +#[test] +fn verrou_sans_pid() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join(LOCK_NAME); + std::fs::write(&p, r#"{"owner":{"pid":0},"via":"cli"}"#).unwrap(); + assert_eq!(read_owner(&p), None); + std::fs::write(&p, r#"{"owner":{"pid":-3},"via":"cli"}"#).unwrap(); + assert_eq!(read_owner(&p), None); + std::fs::write(&p, r#"{"owner":{"pid":7},"via":"cli"}"#).unwrap(); + assert_eq!(read_owner(&p).map(|o| o.owner.pid), Some(7)); + std::fs::write(&p, r#"{"owner":{"pid":"7"}}"#).unwrap(); + assert_eq!(read_owner(&p), None, "illisible"); +} + +/// `tuneProcAlive` : l'instant de démarrage doit concorder s'il est noté ; +/// le binaire, s'il est lisible et noté, doit être l'un des admis. +#[test] +fn meme_processus() { + let id = |s: &str, e: &str| Some((s.to_string(), e.to_string())); + let p = Proc { + pid: 9, + start: "b/1".into(), + exes: vec!["/usr/bin/loki".into(), "/opt/llama-server".into()], + }; + assert!(same_proc(&p, id("b/1", "/opt/llama-server"))); + assert!(same_proc(&p, id("b/1", "/usr/bin/loki"))); + assert!(!same_proc(&p, id("b/2", "/usr/bin/loki")), "PID recyclé"); + assert!(!same_proc(&p, id("b/1", "/usr/bin/autre"))); + // Binaire illisible (droits) : l'instant suffit. + assert!(same_proc(&p, id("b/1", ""))); + assert!(!same_proc(&p, None)); + let bare = Proc { + pid: 9, + ..Default::default() + }; + assert!(same_proc(&bare, id("x", "/quoi/que/ce/soit"))); +} + /// `TestTuneSameExeDeleted`. #[test] fn binaire_remplace() { @@ -222,6 +263,9 @@ fn binaire_remplace() { )); assert!(!same_exe("/usr/local/bin/loki", "/usr/bin/llama-server")); assert!(!same_exe("", "")); + // Un nom vide n'est jamais le même binaire, même face à « . ». + assert!(!same_exe(".", "")); + assert!(!same_exe("", ".")); } /// L'identité d'un processus comme le Go l'écrit : instant de démarrage @@ -291,4 +335,58 @@ fn arret_du_groupe() { waiter.join().unwrap(); assert!(t0.elapsed() < Duration::from_secs(5), "SIGTERM ignoré ?"); assert!(!pid_alive(pid)); + + // Un essai qui ignore SIGTERM : SIGKILL au groupe après le délai. + let mut child = std::process::Command::new("sh") + .args(["-c", "trap '' TERM; while :; do sleep 1; done"]) + .process_group(0) + .spawn() + .unwrap(); + let pid = i64::from(child.id()); + std::thread::sleep(Duration::from_millis(200)); + let reaped = Arc::new(AtomicBool::new(false)); + let r = reaped.clone(); + let waiter = std::thread::spawn(move || { + let _ = child.wait(); + r.store(true, Ordering::SeqCst); + }); + kill_tree_within( + pid, + &|| reaped.load(Ordering::SeqCst), + Duration::from_millis(300), + ); + waiter.join().unwrap(); + assert!(reaped.load(Ordering::SeqCst), "essai toujours là"); +} + +/// `plan_apply` : un résultat qui touche une clé sur option sans l'option +/// (enregistré par une autre version, ou altéré) est refusé avant toute +/// écriture ; avec l'option, le preset est réécrit ; une valeur vide retire +/// la clé. +#[test] +fn application_planifiee() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("presets"); + std::fs::create_dir_all(&dir).unwrap(); + let content = "# NAME=Dense\nCTX=32768\nUBATCH=2048\nBATCH=1024\n"; + std::fs::write(dir.join("dense.env"), content).unwrap(); + let tune = super::job::Tune::new(tmp.path().to_path_buf(), "loki".into()); + let mut res = super::job::TuneResult { + preset_id: "dense".into(), + preset_name: "Dense".into(), + preset_fp: crate::presets::preset_fingerprint(content), + ..Default::default() + }; + res.set.insert("SPEC".into(), "ngram".into()); + assert_eq!(tune.plan_apply(&res), Err("clé SPEC intouchable".into())); + res.opt_in = true; + let (before, patched) = tune.plan_apply(&res).unwrap(); + assert_eq!(before, content); + assert!(patched.contains("SPEC=ngram"), "{patched}"); + res.set.insert("BATCH".into(), String::new()); + let (_, patched) = tune.plan_apply(&res).unwrap(); + assert!( + !crate::presets::parse_env(&patched).contains_key("BATCH"), + "{patched}" + ); } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 26dac21..47704a0 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -81,6 +81,9 @@ pub struct FakeGo { /// /internal/v1/preset/apply écrit-il le preset dans la configuration /// (`applyPresetFile` : clés « appareil » et BIN de la machine gardés) ? pub apply_presets: Mutex, + /// Clés que l'application d'un preset ajoute à la configuration (un + /// réglage changé ailleurs pendant l'application). + pub apply_extra: Mutex>, /// /internal/v1/chat/generating : le Go écrit une réponse. pub generating: Mutex, /// /internal/v1/service/prestart reçus. @@ -571,6 +574,9 @@ async fn handle(st: Arc, req: Request) -> Response if let Some(bin) = cur.get("BIN").filter(|v| !v.is_empty()) { next.insert("BIN".into(), String::from_utf8_lossy(bin).into_owned()); } + for (k, v) in st.apply_extra.lock().unwrap().iter() { + next.insert(k.clone(), v.clone()); + } *cur = next.into_iter().map(|(k, v)| (k, v.into_bytes())).collect(); } st.preset_applies.lock().unwrap().push((path, mode)); diff --git a/rust/loki-rs/tests/tune.rs b/rust/loki-rs/tests/tune.rs index 75114e9..c90b959 100644 --- a/rust/loki-rs/tests/tune.rs +++ b/rust/loki-rs/tests/tune.rs @@ -20,17 +20,25 @@ use loki_rs::tune::job::Tune; use serde_json::{Value, json}; const FAUX: &str = "LOKI_TUNE_FAUX"; +const VRAM_NOTE: &str = "VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés"; const BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la"; // --------------------------------------------------------------------------- // Le faux moteur // --------------------------------------------------------------------------- -/// Ce que le faux moteur annonce : son contexte, et s'il travaille déjà. +/// Ce que le faux moteur annonce : son contexte, s'il travaille déjà, et ses +/// débits (0 : prefill selon le micro-lot, decode à 50 jetons/s) ; `down` : +/// /health en 503 ; `flat_props` : /props sans `default_generation_settings` +/// (le contexte au premier niveau seulement). #[derive(Default)] struct Knobs { n_ctx: Mutex, busy: Mutex, + pps: Mutex, + tg: Mutex, + down: Mutex, + flat_props: Mutex, } /// Un faux llama-server (fil à part, jusqu'à la fin du processus). @@ -73,8 +81,27 @@ fn answer( let mut body = vec![0u8; len]; r.read_exact(&mut body)?; let req: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + // Un seul slot : comme llama-server, un id_slot qu'il n'a pas est refusé. + if path == "/v1/chat/completions" && req["id_slot"].as_i64().is_some_and(|i| i >= 1) { + let b = r#"{"error":{"code":400,"message":"slot id invalide"}}"#; + let mut w = st; + return write!( + w, + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", + b.len() + ); + } + if path == "/health" && *knobs.down.lock().unwrap() { + let mut w = st; + return w.write_all( + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } let out = match path.as_str() { "/health" => json!({ "status": "ok" }), + "/props" if *knobs.flat_props.lock().unwrap() => json!({ + "n_ctx": n_ctx, "total_slots": 1, "build_info": "b7000-faux", + }), "/props" => json!({ "n_ctx": n_ctx, "total_slots": 1, "build_info": "b7000-faux", "default_generation_settings": { "n_ctx": n_ctx }, @@ -103,14 +130,21 @@ fn answer( *l = total; let new = total - cached; // Le prefill ralentit avec le micro-lot ; le decode, non. - let pps = 4_000_000.0 / ubatch as f64; + let pps = match *knobs.pps.lock().unwrap() { + 0.0 => 4_000_000.0 / ubatch as f64, + v => v, + }; + let tg = match *knobs.tg.lock().unwrap() { + 0.0 => 50.0, + v => v, + }; let n_gen = req["max_tokens"].as_i64().unwrap_or(16); json!({ "choices": [{ "message": { "content": "ok" } }], "usage": { "prompt_tokens": total }, "timings": { "prompt_n": new, "prompt_ms": (new as f64 / pps * 1000.0).max(1.0), - "predicted_n": n_gen, "predicted_ms": n_gen as f64 / 50.0 * 1000.0, + "predicted_n": n_gen, "predicted_ms": n_gen as f64 / tg * 1000.0, "cache_n": cached, }, }) @@ -153,14 +187,20 @@ fn serve() { } let mut args = vec!["-m".to_string(), get("MODEL"), "-c".into(), get("CTX")]; for (k, f) in [("UBATCH", "-ub"), ("BATCH", "-b")] { - if !get(k).is_empty() { + if !get(k).is_empty() && !(k == "BATCH" && !get("SANS_BATCH").is_empty()) { args.extend([f.to_string(), get(k)]); } } + // Une ligne qui change ce que le modèle calcule. + if get("DENATURE") == ub { + args.push("--no-mmap".into()); + } args.extend(["--host".into(), get("HOST"), "--port".into(), get("PORT")]); let launch = json!({ "bin": "/opt/faux/llama-server", "args": args, - "probe": { "help": "", "gpus": 0, "vram_mib": 0, "model_bytes": 1000, "moe": false, "cpu_threads": 8 }, + "probe": { "help": "", "gpus": 0, "vram_mib": 0, + "model_bytes": get("MODEL_BYTES").parse::().unwrap_or(1000), "moe": false, + "cpu_threads": get("CPU_THREADS").parse::().unwrap_or(8) }, }); std::fs::write(dir.join("launch.json"), launch.to_string()).unwrap(); return; @@ -168,11 +208,30 @@ fn serve() { if get("ECHEC") == ub { die("llama_model_load: error loading model: out of memory"); } + // Un essai qui ne répond jamais. + if get("MUET") == ub { + std::thread::sleep(Duration::from_secs(600)); + return; + } let port: u16 = get("PORT").parse().unwrap(); let l = TcpListener::bind(("127.0.0.1", port)).unwrap(); let _ = std::io::stdout().write_all(b"load_tensors: offloaded 33/33 layers to GPU\n"); let knobs = Knobs::default(); - *knobs.n_ctx.lock().unwrap() = get("CTX").parse().unwrap(); + // Un essai qui ne garde pas le contexte de la référence. + let ctx: i64 = get("CTX").parse().unwrap(); + *knobs.n_ctx.lock().unwrap() = if get("CTX_FAUX") == ub { ctx / 2 } else { ctx }; + *knobs.flat_props.lock().unwrap() = get("PROPS_PLAT") == "1"; + // Débits imposés par micro-lot : « 1024:2000,512:1953 ». + let speed = |k: &str| { + get(k) + .split(',') + .filter_map(|p| p.split_once(':')) + .find(|(u, _)| *u == ub) + .and_then(|(_, v)| v.parse::().ok()) + .unwrap_or(0.0) + }; + *knobs.pps.lock().unwrap() = speed("VITESSE"); + *knobs.tg.lock().unwrap() = speed("DECODE"); fake_llama(l, ub.parse().unwrap_or(2048), Arc::new(knobs)); // Arrêté par la façade ; un essai oublié (test interrompu) s'en va seul. std::thread::sleep(Duration::from_secs(600)); @@ -195,6 +254,15 @@ struct Tn { const PRESET: &str = "# NAME=Dense\nCTX=32768\nUBATCH=2048\nREASONING=off\nCACHE_ISOLATE=off\n"; async fn start() -> Tn { + start_with(|_, _| {}, |_, _| {}).await +} + +/// [`start`], l'optimiseur réglé par `ft` et la façade par `fo` (le +/// répertoire en second). +async fn start_with( + ft: impl FnOnce(&mut Tune, &std::path::Path), + fo: impl FnOnce(&mut fakego::Opts, &std::path::Path), +) -> Tn { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().to_path_buf(); let mut t = Tune::new(home.clone(), std::env::current_exe().unwrap()); @@ -204,19 +272,19 @@ async fn start() -> Tn { t.settle = Duration::ZERO; t.load_timeout = Duration::from_secs(20); t.lease_ttl = Duration::from_millis(300); + t.gpu_pause = Duration::ZERO; + ft(&mut t, &home); let tune = Arc::new(t); // Moteur et modèle présents : la relance du vrai moteur passe ses // vérifications. std::fs::write(home.join("llama-server"), "x").unwrap(); std::fs::write(home.join("m.gguf"), "x").unwrap(); - let (facade, go, stop, _) = fakego::start_with( - home.clone(), - fakego::Opts { - tune: Some(tune), - ..Default::default() - }, - ) - .await; + let mut opts = fakego::Opts { + tune: Some(tune), + ..Default::default() + }; + fo(&mut opts, &home); + let (facade, go, stop, _) = fakego::start_with(home.clone(), opts).await; // Le vrai moteur, en service. let l = TcpListener::bind("127.0.0.1:0").unwrap(); let port = l.local_addr().unwrap().port(); @@ -276,6 +344,8 @@ impl Tn { async fn wait(&self, what: &str, key: &str) -> Value { for _ in 0..6000 { let st = self.status().await; + // Notre propre verrou n'est jamais « une optimisation ailleurs ». + assert_eq!(st.get("elsewhere"), None, "{st}"); if st[key] != json!(true) { return st; } @@ -284,6 +354,15 @@ impl Tn { panic!("{what} : ne finit pas"); } + /// Application dans le preset ; son issue (`apply` de l'état). + async fn apply_preset(&self) -> Value { + let (code, out) = self + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!((code, out), (202, json!({ "ok": true }))); + self.wait("application", "applying").await["apply"].clone() + } + fn config(&self) -> HashMap { ["MODEL", "CTX", "UBATCH", "BATCH", "PORT"] .iter() @@ -337,11 +416,49 @@ async fn optimisation_complete() { tokio::time::sleep(Duration::from_millis(5)).await; } assert!(seen_lock, "verrou jamais posé"); + // Chaque essai lancé est noté dans le verrou : un Loki tué pendant la + // mesure l'arrête à son redémarrage. + let mut trial = None; + for _ in 0..4000 { + if let Some(t) = + loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")).and_then(|o| o.trial) + { + trial = Some(t); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let trial = trial.expect("essai jamais noté dans le verrou"); + assert!(trial.pid > 0 && trial.pid != i64::from(std::process::id())); + assert_eq!( + trial.exes[0], + std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned() + ); let (code, out) = tn.call("POST", "/api/tune", "{}").await; assert_eq!(code, 409); assert_eq!(out["error"], json!(BUSY)); + // Rien ne s'applique pendant la mesure. + assert_eq!( + tn.call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await, + (409, json!({ "ok": false, "error": BUSY })) + ); let st = tn.wait("optimisation", "running").await; assert_eq!(st["error"], Value::Null, "{st}"); + // La progression : la référence puis chaque essai, une fois chacun. + let shown: Vec<(&str, &str)> = st["trials"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["label"].as_str().unwrap(), t["status"].as_str().unwrap())) + .collect(); + assert_eq!(shown.len(), 5, "{shown:?}"); + assert_eq!(shown[0], ("configuration actuelle", "mesuré")); + assert!(shown.iter().all(|t| t.1 != "en cours"), "{shown:?}"); + assert_eq!(st["eta_sec"], json!(0)); let res = &st["result"]; assert_eq!(res["preset_id"], json!("dense")); assert_eq!(res["preset_name"], json!("Dense")); @@ -366,6 +483,9 @@ async fn optimisation_complete() { ] ); assert_eq!(tr[0].1, "retenu", "{tr:?}"); + // Confirmé seulement s'il promet : le premier, pas les suivants. + let runs = |i: usize| res["trials"][i]["runs"].as_array().unwrap().len(); + assert_eq!((runs(0), runs(1), runs(2), runs(3)), (2, 1, 1, 1)); assert!( tr[0].2.starts_with('−') && tr[0].2.contains("par tour (seuil"), "{tr:?}" @@ -374,8 +494,19 @@ async fn optimisation_complete() { res["best"], json!(tr.iter().rfind(|t| t.1 == "retenu").unwrap().0) ); - assert_eq!(res["set"]["UBATCH"], json!("512")); - assert!(res["gain"].as_f64().unwrap() > 0.03, "{res}"); + assert_eq!(res["set"], json!({ "BATCH": "2048", "UBATCH": "512" })); + assert_eq!(res["old"], json!({ "BATCH": "", "UBATCH": "2048" })); + let (base_turn, best_turn) = ( + res["baseline"]["turn_sec"].as_f64().unwrap(), + res["trials"][0]["turn_sec"].as_f64().unwrap(), + ); + let gain = res["gain"].as_f64().unwrap(); + assert!( + (gain - (base_turn - best_turn) / base_turn).abs() < 1e-12, + "{gain} {base_turn} {best_turn}" + ); + // Aussi rapide que le meilleur : « ≈ 0 % ». + assert_eq!(tr[1].2, "≈ 0 % (seuil 3.0 %)"); let skipped: Vec<&str> = res["skipped"] .as_array() .unwrap() @@ -383,12 +514,7 @@ async fn optimisation_complete() { .map(|s| s.as_str().unwrap()) .collect(); assert!(skipped.contains(&"marges : non demandé"), "{skipped:?}"); - assert!( - res["notes"] - .as_array() - .unwrap() - .contains(&json!("VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés")) - ); + assert_eq!(res["notes"], json!([VRAM_NOTE])); // Le vrai moteur : arrêté, puis relancé, sans le refus de l'optimiseur ; // la dictée coupée ; le verrou rendu ; le bail aussi. assert_eq!(*tn.go.service_os.lock().unwrap(), ["stop", "start"]); @@ -410,6 +536,14 @@ async fn optimisation_complete() { assert_eq!(last["ok"], json!(true)); assert_eq!(last["preset_changed"], json!(false)); assert_eq!(last["result"], *res); + // Le build du moteur actuel est inconnu (binaire illisible) : rien à + // signaler. + assert_eq!(last.get("engine_changed"), None, "{last}"); + assert_eq!( + tn.call("POST", "/api/tune/apply", r#"{"target":"ailleurs"}"#) + .await, + (400, json!({ "ok": false, "error": "cible inconnue" })) + ); // Copie : rien d'autre ne bouge. let (code, out) = tn @@ -743,7 +877,19 @@ async fn orphelin_et_application_interrompue() { .spawn() .unwrap(); let pid = i64::from(child.id()); - let (start, exe) = loki_rs::tune::lock::proc_identity(pid).unwrap(); + // L'identité une fois l'exec visible dans /proc : lue trop tôt, elle + // porterait l'exécutable du test, et l'essai ne serait plus reconnu. + let mut ident = None; + for _ in 0..200 { + if let Some((start, exe)) = loki_rs::tune::lock::proc_identity(pid) + && exe.ends_with("sleep") + { + ident = Some((start, exe)); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let (start, exe) = ident.expect("exec de sleep jamais visible"); let dead = json!({ "pid": 999999, "start": "x" }); let write = |trial: Value, extra: Value| { let mut o = json!({ "owner": dead, "via": "cli", "phase": "essais", "since": 1, "trial": trial, "main_was_active": false }); @@ -800,6 +946,16 @@ async fn orphelin_et_application_interrompue() { old ); assert_eq!(tn.config()["UBATCH"], "2048"); + // Hors phase d'application, rien n'est défait. + tn.preset(&PRESET.replace("UBATCH=2048", "UBATCH=999")); + write( + Value::Null, + json!({ "phase": "essais", "backup": backup, "preset_id": "dense", "preset_name": "Dense" }), + ); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + tn.wait("essais interrompus", "running").await; + assert_eq!(tn.config()["UBATCH"], "999"); } /// Sans la façade pour l'optimiseur, ses routes partent au Go. @@ -819,3 +975,488 @@ async fn sans_la_facade() { assert_eq!(String::from_utf8_lossy(&b), format!("go:{p}")); } } + +/// Pendant la prise du bail, un second lancement est refusé. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn second_lancement_pendant_le_bail() { + let tn = Arc::new(start().await); + *tn.go.lease_delay.lock().unwrap() = Some(Duration::from_millis(400)); + let first = { + let tn = tn.clone(); + tokio::spawn(async move { + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(150)).await; + // Avant la prise du bail : aucune estimation encore. + assert_eq!(tn.status().await["running"], json!(false)); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + (409, json!({ "ok": false, "error": BUSY })) + ); + assert_eq!(first.await.unwrap().0, 202); + let st = tn.status().await; + if st["running"] == json!(true) { + assert_eq!( + st["eta_sec"], + json!(-1), + "aucun essai mesuré : ETA inconnue" + ); + } + tn.wait("optimisation", "running").await; + assert_eq!(*tn.go.lease_whys.lock().unwrap(), vec!["tune".to_string()]); +} + +/// Une application qui échoue avant d'écrire quoi que ce soit (une autre +/// optimisation tient le moteur) : l'erreur, sans journal. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn application_refusee_sans_journal() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["set"]["UBATCH"], json!("512"), "{st}"); + let other = json!({ "owner": { "pid": 1 }, "via": "cli", "phase": "essais", "since": 1700000000, "main_was_active": false }); + std::fs::write(tn.home.join("tune.lock"), other.to_string()).unwrap(); + let (code, _) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!(code, 202); + let mut st = tn.status().await; + for _ in 0..500 { + if st["applying"] == json!(false) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + st = tn.status().await; + } + let apply = &st["apply"]; + assert_eq!(apply["log"], Value::Null, "{st}"); + assert_eq!(apply["message"], json!("")); + assert!( + apply["error"] + .as_str() + .unwrap() + .starts_with("une optimisation tourne déjà (cli, PID 1, depuis "), + "{st}" + ); + assert_eq!(st["elsewhere"], json!("cli")); + assert!( + std::fs::read_to_string(tn.home.join("presets/dense.env")) + .unwrap() + .contains("UBATCH=2048"), + "rien n'est écrit" + ); + std::fs::remove_file(tn.home.join("tune.lock")).unwrap(); +} + +/// La façade pilote elle-même le moteur (LOKI_ENGINE=rust) : arrêt et +/// relance par son superviseur, pas par le Go. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn moteur_pilote_par_la_facade() { + use std::os::unix::fs::PermissionsExt; + let tn = start_with( + |_, _| {}, + |o, home| { + let script = home.join("superviseur.sh"); + std::fs::write( + &script, + "#!/bin/sh\necho \"$1 $2\" >> \"$SUPERVISEUR_LOG\"\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let ctl = loki_rs::enginectl::EngineCtl::new( + script, + vec![( + "SUPERVISEUR_LOG".into(), + home.join("superviseur.log").to_string_lossy().into_owned(), + )], + ); + o.engine_ctl = Some(Arc::new(ctl)); + // Le moteur tourne : son PID, ce processus. + let pid = loki_rs::engine::Paths { + home: home.to_path_buf(), + service: "loki-engine".into(), + } + .pid(); + std::fs::create_dir_all(pid.parent().unwrap()).unwrap(); + std::fs::write(pid, format!("{}\n", std::process::id())).unwrap(); + }, + ) + .await; + *tn.go.service_active.lock().unwrap() = false; + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["error"], Value::Null, "{st}"); + assert_eq!( + std::fs::read_to_string(tn.home.join("superviseur.log")).unwrap(), + "engine stop\nengine start\n" + ); + assert!( + tn.go.service_os.lock().unwrap().is_empty(), + "rien par le Go" + ); +} + +/// Les essais écartés après composition : ligne dénaturée, ligne déjà +/// mesurée (doublon), contexte perdu au chargement. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn essais_ecartes() { + let tn = start().await; + tn.preset(&format!( + "{PRESET}DENATURE=1024\nCTX_FAUX=512\nSANS_BATCH=1\n" + )); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!(tr.len(), 4, "{tr:?}"); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "écarté".into(), + "dénature : contexte 16384 × 1 slot(s) au lieu de 32768 × 1".into() + ) + ); + assert_eq!( + tr[1], + ( + "UBATCH=512 BATCH=1024".into(), + "doublon".into(), + "même ligne de commande que « UBATCH=512 BATCH=2048 »".into() + ) + ); + assert_eq!(tr[2].1, "écarté"); + assert!( + tr[2].2.starts_with("dénature : ") && tr[2].2.contains("--no-mmap"), + "{tr:?}" + ); + assert_eq!( + tr[3], + ( + "UBATCH=2048 BATCH=4096".into(), + "doublon".into(), + "même ligne de commande que « configuration actuelle »".into() + ) + ); + assert_eq!(st["result"]["best"], Value::Null); +} + +/// La VRAM mesurée (faux nvidia-smi) : marge libre après chaque mesure, et +/// les essais trop justes écartés. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn vram_mesuree() { + use std::os::unix::fs::PermissionsExt; + let tn = start_with( + |t, home| { + let smi = home.join("nvidia-smi"); + std::fs::write( + &smi, + format!("#!/bin/sh\ncat '{}'\n", home.join("smi.txt").display()), + ) + .unwrap(); + std::fs::set_permissions(&smi, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(home.join("smi.txt"), "Fausse, 1000, 24000, 0, 40\n").unwrap(); + t.nvidia_smi = smi.to_string_lossy().into_owned(); + }, + |_, _| {}, + ) + .await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let res = &st["result"]; + assert_eq!(res["baseline"]["headroom_mib"], json!(23000), "{res}"); + assert_eq!(res["trials"][0]["headroom_mib"], json!(23000)); + assert_eq!(res["trials"][0]["status"], json!("retenu")); + assert_eq!(res["notes"], Value::Null, "VRAM mesurable : aucune note"); + // Une carte presque pleine : tout essai est trop juste. + std::fs::write(tn.home.join("smi.txt"), "Fausse, 23500, 24000, 0, 40\n").unwrap(); + tn.preset(&format!("{PRESET}MMPROJ=/models/vision.gguf\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("trop juste", "running").await; + let res = &st["result"]; + assert_eq!( + res["notes"], + json!(["la configuration actuelle laisse déjà moins de 1280 Mio libres sur une carte"]) + ); + let tr = trials(res); + assert!( + tr.iter() + .all(|t| t.1 == "écarté" && t.2 == "trop juste : 500 Mio libres sur une carte (< 1280)"), + "{tr:?}" + ); + assert_eq!(res["best"], Value::Null); +} + +/// Le budget atteint : les essais restants ne sont pas faits. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn budget_atteint() { + let tn = start_with(|t, _| t.budget_unit = Duration::from_millis(100), |_, _| {}).await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"],"budget_min":1}"#) + .await; + let st = tn.wait("budget", "running").await; + assert_eq!( + st["result"]["partial"], + json!("budget de 0s atteint : 4 essai(s) non faits") + ); + assert_eq!(st["result"]["trials"], Value::Null); +} + +/// La profondeur : celle que la référence tient avec le plus gros +/// préchauffage essayé. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn profondeur_commune() { + let tn = start().await; + // Un moteur qui ne donne le contexte qu'au premier niveau de /props. + tn.preset(&format!( + "{}PROPS_PLAT=1\n", + PRESET.replace("CTX=32768", "CTX=20000") + )); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["depth"], json!(7288), "{st}"); + assert_eq!(st["result"]["n_ctx"], json!(20000)); +} + +/// Les notes : modèle plus gros que la RAM ; relance du vrai moteur qui +/// échoue après une optimisation réussie ; poids tirés de la télémétrie ; +/// essais d'une optimisation précédente effacés. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn notes_et_poids() { + let tn = start().await; + let (_, total_mb) = loki_rs::sysinfo::ram_usage_mb(); + assert!(total_mb > 0); + // La moitié de la RAM : pas de note (des octets, comparés à des Mio). + tn.preset(&format!("{PRESET}MODEL_BYTES={}\n", total_mb << 19)); + let stale = tn.home.join("tune/run/e09"); + std::fs::create_dir_all(&stale).unwrap(); + let recs: Vec = (0..20) + .map(|_| json!({ "kind": "main", "complete": true, "total": 4000, "cached": 0, "new": 1000, "gen": 100 })) + .collect(); + *tn.go.tune_perf.lock().unwrap() = Value::Array(recs); + *tn.go.service_os_fail.lock().unwrap() = Some(("start".into(), "boum".into())); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + let res = &st["result"]; + assert_eq!( + res["notes"], + json!([VRAM_NOTE, "⚠️ relance du moteur principal : boum"]), + "{res}" + ); + assert_eq!( + res["weights"], + json!({ "k": 1000, "g": 100, "f_cold": 1, "from": "télémétrie" }) + ); + assert!(!stale.exists(), "essais précédents effacés"); + // Enregistré AVANT la relance : sans sa note. + let saved: Value = serde_json::from_str(&tn.go.get("state", "tune_results").unwrap()).unwrap(); + assert_eq!(saved["dense"]["notes"], json!([VRAM_NOTE])); + *tn.go.service_os_fail.lock().unwrap() = None; + // Deux fois la RAM : la note. + tn.preset(&format!("{PRESET}MODEL_BYTES={}\n", total_mb << 21)); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!( + st["result"]["notes"], + json!([ + VRAM_NOTE, + "modèle plus gros que la RAM : chaque essai le relit depuis le disque — prévoir un budget plus long, ou ne garder que quelques étapes" + ]) + ); +} + +/// Ce que la mesure dit d'un essai : un gain sous le seuil, un écart nul, +/// un decode en prose plus lent. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ecarts_de_mesure() { + let tn = start().await; + // 512 refusé : la référence reste la meilleure ; 1024 à peine plus + // rapide ; 2048/4096 identique. + tn.preset(&format!("{PRESET}REFUS=512\nVITESSE=1024:1990\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!(tr.len(), 4, "{tr:?}"); + assert_eq!(tr[2].0, "UBATCH=1024 BATCH=2048"); + assert_eq!(tr[2].1, "mesuré"); + assert!( + tr[2].2.starts_with('−') && tr[2].2.ends_with(" %, sous le seuil de 3 %"), + "{tr:?}" + ); + assert_eq!( + st["result"]["trials"][2]["runs"].as_array().unwrap().len(), + 1 + ); + assert_eq!( + tr[3], + ( + "UBATCH=2048 BATCH=4096".into(), + "mesuré".into(), + "≈ 0 % (seuil 3.0 %)".into() + ) + ); + // Un prefill bien plus rapide, mais un decode en prose plus lent de 20 %. + tn.preset(&format!("{PRESET}REFUS=1024\nDECODE=512:40\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("decode", "running").await; + let tr = trials(&st["result"]); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "mesuré".into(), + "decode en prose plus lent de 20 %".into() + ), + "{tr:?}" + ); + assert_eq!(st["result"]["best"], Value::Null); +} + +/// L'application dans le preset, gardée : il doit être celui en service, et +/// pas une API externe ; une configuration changée ailleurs pendant +/// l'application la défait ; la sauvegarde est notée dans le verrou avant +/// toute écriture. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn application_gardee() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["set"]["UBATCH"], json!("512"), "{st}"); + let old = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(); + + // La configuration active n'est plus ce preset. + tn.go.set("config", "UBATCH", "1024"); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!( + "ce preset n'est plus celui en service : bascule dessus, ou enregistre le résultat dans une copie" + ), + "{apply}" + ); + assert_eq!(apply["log"], Value::Null); + tn.go.set("config", "UBATCH", "2048"); + + // Une API externe. + tn.go.set("config", "EXTERNAL", "1"); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!("le preset actif est une API externe"), + "{apply}" + ); + tn.go.del("config", "EXTERNAL"); + + // Une clé changée ailleurs pendant l'application : l'ancienne version + // revient, sans relance sur la nouvelle. + tn.go + .apply_extra + .lock() + .unwrap() + .push(("THREADS".into(), "7".into())); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!( + "la configuration a changé ailleurs que sur les clés réglées depuis la mesure : ancienne version rétablie" + ), + "{apply}" + ); + assert_eq!( + std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(), + old + ); + assert_eq!( + *tn.go.service_os.lock().unwrap(), + ["stop", "start", "restart"] + ); + tn.go.apply_extra.lock().unwrap().clear(); + tn.preset(PRESET); + + // Le moteur relancé tarde : pendant l'attente, le verrou désigne la + // sauvegarde (un Loki tué la remet au redémarrage). + *tn.main.down.lock().unwrap() = true; + let (code, _) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!(code, 202); + let mut noted = None; + for _ in 0..1000 { + if let Some(o) = loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")) + && !o.backup.is_empty() + { + noted = Some(o); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let o = noted.expect("sauvegarde jamais notée dans le verrou"); + assert_eq!( + ( + o.phase.as_str(), + o.preset_id.as_str(), + o.preset_name.as_str() + ), + ("application", "dense", "Dense") + ); + assert_eq!(std::fs::read_to_string(&o.backup).unwrap(), old); + *tn.main.down.lock().unwrap() = false; + let apply = tn.wait("application", "applying").await["apply"].clone(); + assert_eq!( + (apply["error"].clone(), apply["message"].clone()), + (json!(""), json!("preset « Dense » optimisé et vérifié")), + "{apply}" + ); + assert!(!tn.home.join("tune.lock").exists(), "verrou non rendu"); +} + +/// Un essai qui ne répond jamais : abandonné au bout du délai de chargement. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn essai_muet() { + let tn = start_with(|t, _| t.load_timeout = Duration::from_secs(2), |_, _| {}).await; + tn.preset(&format!("{PRESET}MUET=512\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "échec".into(), + "pas prêt après 2s".into() + ), + "{tr:?}" + ); +} + +/// SIDE_SLOT sur un moteur à un seul slot : les mesures restent sur le +/// slot 0. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn second_slot_absent() { + let tn = start().await; + tn.preset(&format!("{PRESET}SIDE_SLOT=on\n")); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["error"], Value::Null, "{st}"); + assert_eq!(st["result"]["baseline"]["status"], json!("mesuré"), "{st}"); +} + +/// Les threads essayés : comptés sur les cœurs physiques que la sonde du +/// moteur annonce (des poids sur CPU). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn threads_de_la_sonde() { + let tn = start().await; + tn.preset(&format!("{PRESET}EXTRA_ARGS=--cpu-moe\nCPU_THREADS=13\n")); + tn.call("POST", "/api/tune", r#"{"stages":["threads"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + let labels: Vec = trials(&st["result"]).into_iter().map(|t| t.0).collect(); + assert_eq!(labels, ["THREADS=12", "THREADS=6"], "{st}"); +}