mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
Lot 28 : tests contre les mutations survivantes de l'optimiseur
105 mutations du verrou et du job ; le premier passage en tuait 39. Les survivantes le sont désormais par des tests ajoutés : - l'essai noté dans le verrou pendant la mesure, la sauvegarde notée avant d'écrire, l'application refusée pendant la mesure, la cible inconnue ; - l'application gardée : preset qui n'est plus en service, API externe, clé changée ailleurs pendant l'application (défaite) ; - l'application planifiée : clé sur option refusée sans l'option ; - l'essai muet (délai de chargement), /props sans default_generation_settings, SIDE_SLOT sur un seul slot, threads comptés sur les cœurs de la sonde, aucun changement de moteur signalé quand son build est inconnu ; - VRAM mesurée par un faux nvidia-smi, budget, profondeur, notes et poids, écarts de mesure, essais écartés, moteur piloté par la façade. nvidia-smi, la pause entre deux lectures de VRAM et l'unité du budget deviennent réglables (tests). Le test de l'essai orphelin lit l'identité du processus une fois l'exec visible dans /proc : lue trop tôt, elle portait l'exécutable du test (échec intermittent en CI). Les vingt survivantes restantes, équivalentes ou hors de portée d'un test (courses, estimation seulement), sont décrites dans la spec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
6 files changed
+963
-52
No files matched your search
@@ -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::GpuStat> {
|
||||
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<AtomicBool>)>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user