mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 28 : optimiseur tenu par la façade (LOKI_TUNE=rust)
Avec LOKI_TUNE=rust (expérimental, Unix seulement), la façade sert /api/tune, /api/tune/status, /api/tune/cancel, /api/tune/apply et /api/tune/last, et déroule l'optimisation sans perte (rust/loki-rs/src/tune) : vrai moteur arrêté, essais composés à blanc puis lancés par les « loki serve » du Go (LOKI_TUNE_TRIAL), chacun dans son groupe de processus, mesurés par le bench complet à profondeur fixe, retenus au-delà du bruit ; vrai moteur relancé. Rien n'est écrit sans un clic : copie du preset, ou le preset lui-même — redémarrage, sonde, et ancienne version rétablie si elle échoue. - tune/core.rs : la partie pure (EXTRA_ARGS, réglages, essais de chaque étape, garde-fous, diff et réécriture du preset, score) ; - tune/lock.rs : le verrou tune.lock au format du Go, identité des processus comme procIdentity (la reprise du Go reconnaît la façade vivante, et écarte son verrou si elle meurt), ménage d'un verrou périmé (essai orphelin arrêté, application interrompue défaite) ; - tune/job.rs : routes, job, essais et application. Le verrou de génération reste celui du Go, pris par le bail du moteur au motif « tune » (refus de l'optimiseur pour les autres, stop du chat qui annule). Le Go prête /internal/v1/tune/context (bench, génération, jobs du mode code, état du moteur, télémétrie) et le démarrage ou l'arrêt du moteur sans le refus de l'optimiseur (service/action « os »). Hors Unix, la façade passe LOKI_TUNE vide au Go, qui garde l'optimiseur. serde_json lit désormais les flottants à l'arrondi exact (float_roundtrip), comme le Go. Parité : parity_tune_test.go tire du Go un journal (partie pure, contenu du verrou, résultat enregistré, vérification après application) rejoué par tests/tune_core.rs. tests/tune.rs déroule de vraies optimisations, le binaire de test jouant « loki serve » ; les tests du verrou du Go sont portés ; TestFacadeTune côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
31 files changed
+36838
-25
No files matched your search
@@ -242,6 +242,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go |
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Lot 28 — Optimiseur tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur
|
||||
plus rapides **sans rien changer à ce que le modèle calcule** : même
|
||||
contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il
|
||||
procède ainsi :
|
||||
|
||||
1. Il arrête le vrai moteur.
|
||||
2. Il lance chaque essai sur une COPIE de la configuration :
|
||||
- posée dans `LOKI_HOME/tune/run/<essai>/config.env`, jamais
|
||||
`config.env` ;
|
||||
- composée par « loki serve » à blanc ;
|
||||
- puis lancée sur 127.0.0.1 et un port libre.
|
||||
3. Il mesure chaque essai par le bench complet, à une profondeur fixe.
|
||||
4. Il garde ce qui gagne au-delà du bruit.
|
||||
5. Il relance le vrai moteur.
|
||||
|
||||
Rien n'est écrit dans un preset sans un geste de l'utilisateur : dans une
|
||||
copie, ou dans le preset lui-même — moteur redémarré, sondé, et ancienne
|
||||
version rétablie si la sonde échoue.
|
||||
|
||||
Cinq routes :
|
||||
|
||||
- `POST /api/tune` lance (202) ;
|
||||
- `GET /api/tune/status` dit la phase, les essais, l'ETA, puis le résultat ;
|
||||
- `POST /api/tune/cancel` annule ;
|
||||
- `POST /api/tune/apply` écrit le résultat, sur un clic (`copy` ou
|
||||
`preset`) ;
|
||||
- `GET /api/tune/last` rend le dernier résultat d'un preset, et si le preset
|
||||
ou le moteur ont changé depuis.
|
||||
|
||||
Le moteur, pendant une optimisation, appartient aux essais. Le verrou est un
|
||||
**fichier** de `LOKI_HOME` (`tune.lock`), créé de façon exclusive, que tout
|
||||
le monde lit :
|
||||
|
||||
- le Go, dans tous ses refus (`tuneGuard`) ;
|
||||
- « loki tune » en ligne de commande ;
|
||||
- désormais la façade.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_TUNE=rust` (expérimental, Unix seulement), la façade
|
||||
(`rust/loki-rs/src/tune`) sert les cinq routes et déroule l'optimisation :
|
||||
|
||||
- `tune/core.rs` : la partie pure, portée à l'octet près :
|
||||
- découpe et réécriture d'`EXTRA_ARGS` ;
|
||||
- réglages par clé ou par drapeau ;
|
||||
- essais de chaque étape selon la machine et les cases cochées ;
|
||||
- garde-fous : cœur de la ligne, dénature, clés permises ;
|
||||
- diff et réécriture du preset ;
|
||||
- journal d'un essai ;
|
||||
- score et départage ;
|
||||
- `tune/lock.rs` : le verrou, **au format du Go** :
|
||||
- contenu du fichier, clé de prise, binaires comparés par leur nom ;
|
||||
- identité d'un processus comme `procIdentity` l'écrit : champ 22 de
|
||||
`/proc/<pid>/stat`, préfixé de l'identifiant du démarrage ; `ps` ailleurs ;
|
||||
- écriture d'un bloc ;
|
||||
- rendu neutre avant retrait ;
|
||||
- ménage par déplacement-relecture ;
|
||||
- `tune/job.rs` : les routes, le job, les essais et l'application.
|
||||
|
||||
**La reprise reste au Go.** Sa reprise (`tuneRecoverWatch`, toutes les
|
||||
30 s) lit le verrou de la façade et la reconnaît vivante, par la même
|
||||
identité. Si la façade meurt en plein essai, le Go écarte son verrou comme
|
||||
celui d'un « loki tune » tué :
|
||||
|
||||
- l'essai orphelin est arrêté (identité complète vérifiée) ;
|
||||
- une application interrompue est défaite ;
|
||||
- le moteur est relancé s'il tournait.
|
||||
|
||||
La façade fait le même ménage quand elle trouve un verrou périmé en prenant
|
||||
le sien.
|
||||
|
||||
**Les essais restent des « loki serve » du Go**, lancés par la façade avec
|
||||
`LOKI_TUNE_TRIAL` (et `LOKI_TUNE_DRYRUN` à blanc), dans leur propre groupe de
|
||||
processus. Le groupe est arrêté en sortie, quoi qu'il arrive. L'essai est
|
||||
noté dans le verrou avant de charger quoi que ce soit.
|
||||
|
||||
Le verrou de génération reste celui de la conversation du Go. La façade le
|
||||
prend par le **bail du moteur**, avec le motif `tune` :
|
||||
|
||||
- le Go note alors `benching` et donne aux autres le refus de l'optimiseur ;
|
||||
- changer de discussion ne rompt pas le bail ;
|
||||
- un stop du chat, un Reset ou un bail perdu annulent l'optimisation.
|
||||
|
||||
Le Go prête à la façade ce qu'il est seul à voir
|
||||
(`/internal/v1/tune/context`) :
|
||||
|
||||
- **le refus du processus web** : un benchmark (le sien ou celui de la
|
||||
façade), une génération, des jobs d'arrière-plan du mode code ;
|
||||
- **l'état du vrai moteur** (`engineNeedsStop`) ;
|
||||
- **la télémétrie**, d'où viennent les poids d'un tour type.
|
||||
|
||||
Il prête aussi le démarrage et l'arrêt du moteur **sans le refus de
|
||||
l'optimiseur** (`/internal/v1/service/action`, `os`), puisque c'est la
|
||||
façade qui tient le verrou. Si la façade pilote elle-même le moteur
|
||||
(`LOKI_ENGINE=rust`), elle passe par son superviseur.
|
||||
|
||||
Le preset est réécrit et appliqué par les fonctions des presets
|
||||
(`save_applying`, mode `apply` : la configuration, sans redémarrage). Le
|
||||
redémarrage, la sonde et le retour arrière sont ceux de l'optimiseur.
|
||||
|
||||
La ligne de commande (`loki tune`) reste au Go.
|
||||
|
||||
Hors Unix, la façade ne tient pas l'optimiseur. Elle passe alors
|
||||
`LOKI_TUNE` vide au Go, qui le garde et ne relaie rien.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tune_test.go` tire du Go un journal
|
||||
(`tests/parity_tune/core.json`) rejoué par `tests/tune_core.rs`. Il couvre :
|
||||
|
||||
- toute la partie pure ;
|
||||
- le **verrou** : contenu du fichier octet pour octet, clé de prise,
|
||||
comparaison des binaires ;
|
||||
- le **résultat** tel qu'enregistré et montré (listes vides écrites `null`
|
||||
comme une tranche nil du Go) ;
|
||||
- la vérification **après application** (la configuration est-elle l'essai
|
||||
retenu ?).
|
||||
|
||||
`tests/tune.rs` déroule de vraies optimisations, essais compris : le binaire
|
||||
de test joue « loki serve ».
|
||||
|
||||
- À blanc, il écrit la ligne de commande de l'essai.
|
||||
- Pour de vrai, il sert un faux llama-server dont le prefill est d'autant
|
||||
plus rapide que le micro-lot est petit.
|
||||
|
||||
Les tests couvrent :
|
||||
|
||||
- **une optimisation entière** :
|
||||
- bail « tune » et verrou au nom de la façade ;
|
||||
- vrai moteur arrêté puis relancé sans le refus de l'optimiseur, dictée
|
||||
coupée ;
|
||||
- quatre essais composés, lancés, mesurés, l'un retenu ;
|
||||
- copies de configuration sur une adresse privée ;
|
||||
- résultat enregistré par preset et rendu par `/api/tune/last` ;
|
||||
- **l'application** :
|
||||
- dans une copie (rien d'autre ne bouge) ;
|
||||
- dans le preset (réécrit, appliqué, moteur redémarré et sondé) ;
|
||||
- refusée sur un preset changé depuis la mesure ;
|
||||
- **une sonde qui échoue** (contexte perdu) : preset et configuration
|
||||
rétablis, moteur redémarré dessus, sauvegarde faite avant d'écrire ;
|
||||
- **les essais refusés** : à la composition, au chargement (mémoire
|
||||
insuffisante) ; la référence qui ne se compose pas ; la relance qui
|
||||
échoue, dite dans l'erreur ;
|
||||
- **l'annulation**, par la route et par le stop du chat ;
|
||||
- **les refus d'entrée**, avec les messages du Go :
|
||||
- preset externe, Strata, aucun preset actif ;
|
||||
- jobs du mode code, moteur occupé, génération en cours ;
|
||||
- optimisation d'un autre processus (et `elsewhere` dans le statut) ;
|
||||
- **un verrou périmé** :
|
||||
- l'essai orphelin arrêté seulement si son identité concorde ;
|
||||
- une application interrompue défaite ;
|
||||
- les routes sans la façade.
|
||||
|
||||
Les tests du verrou du Go sont portés (`tune/tests.rs`) :
|
||||
|
||||
- prise, refus, essai noté, rendu ;
|
||||
- propriétaire mort ;
|
||||
- verrou à notre PID qui n'est pas l'un des nôtres ;
|
||||
- illisible neuf ou vieux ;
|
||||
- ménage jamais sur un verrou vivant ;
|
||||
- binaire remplacé ;
|
||||
- identité d'un processus ;
|
||||
- arrêt du groupe.
|
||||
|
||||
Côté Go, `TestFacadeTune` vérifie :
|
||||
|
||||
- les cinq routes partent à la façade, et elles seules ;
|
||||
- le bail « tune » : refus, bascule de discussion, libération ;
|
||||
- le contexte : au repos, pendant l'optimisation, pendant un bench ;
|
||||
- le démarrage et l'arrêt sans le refus de l'optimiseur.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une sortie à blanc qui échoue est lue dans un fichier où vont les deux
|
||||
sorties (comme `CombinedOutput`), mais une erreur de lecture de
|
||||
`launch.json` ou d'une requête garde le texte de Rust.
|
||||
- Un niveau de raisonnement appris pendant les essais n'est pas transmis au
|
||||
chat du Go quand le Go tient la conversation.
|
||||
@@ -109,7 +109,7 @@ func TestFacadeBench(t *testing.T) {
|
||||
conv.extLeaseClearLocked()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
if _, err := conv.extLeaseAcquire(5*time.Second, true); err != nil {
|
||||
if _, err := conv.extLeaseAcquire(5*time.Second, errBenchBusy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
|
||||
@@ -183,6 +183,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
held, unavailable = facadeTasks, "tâches indisponibles : "
|
||||
case facadeBenchRoutes[path]:
|
||||
held, unavailable = facadeBench, "banc d'essai indisponible : "
|
||||
case facadeTuneRoutes[path]:
|
||||
held, unavailable = facadeTune, "optimiseur indisponible : "
|
||||
default:
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -64,6 +64,9 @@ func handleInternalCDPShutdown(w http.ResponseWriter, r *http.Request) {
|
||||
// internalServiceAction : serviceAction, variable pour les tests.
|
||||
var internalServiceAction = serviceAction
|
||||
|
||||
// internalServiceActionOS : serviceActionOS, variable pour les tests.
|
||||
var internalServiceActionOS = func(a string) error { return serviceActionOSFn(a) }
|
||||
|
||||
// handleInternalServiceAction : POST {action, if_active} → 200 {ran, error}.
|
||||
// if_active : seulement si le moteur tourne (une clé neuve ne s'applique qu'au
|
||||
// redémarrage, inutile de démarrer un moteur arrêté pour elle).
|
||||
@@ -75,6 +78,9 @@ func handleInternalServiceAction(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
Action string `json:"action"`
|
||||
IfActive bool `json:"if_active"`
|
||||
// OS : sans le refus de l'optimiseur (serviceActionOS) — pour la
|
||||
// façade qui tient elle-même son verrou (LOKI_TUNE=rust).
|
||||
OS bool `json:"os"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
sendJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
||||
@@ -90,9 +96,16 @@ func handleInternalServiceAction(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, http.StatusOK, map[string]any{"ran": false, "error": ""})
|
||||
return
|
||||
}
|
||||
act := internalServiceAction
|
||||
if in.OS {
|
||||
act = internalServiceActionOS
|
||||
}
|
||||
msg := ""
|
||||
if err := internalServiceAction(in.Action); err != nil {
|
||||
if err := act(in.Action); err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
if in.OS {
|
||||
gpuMonitor.invalidate()
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"ran": true, "error": msg})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// facade_tune.go — l'optimiseur tenu par la façade Rust (LOKI_TUNE=rust, lot
|
||||
// 28 de la migration ; expérimental, le Go reste le défaut) : les routes
|
||||
// /api/tune*, le job, les essais et l'application (rust/loki-rs/src/tune).
|
||||
//
|
||||
// Le verrou de l'optimiseur reste le FICHIER de LOKI_HOME, au même format
|
||||
// (backend_tune_lock.go) : le Go le lit (tuneGuard), et sa reprise
|
||||
// (tuneRecoverWatch) écarte celui d'une façade morte — essai orphelin arrêté,
|
||||
// application défaite, moteur relancé. Le verrou de génération : le bail du
|
||||
// moteur, motif « tune » (errTuneBusy pour les autres, le stop du chat
|
||||
// annule). Les essais restent des « loki serve » du Go (LOKI_TUNE_TRIAL).
|
||||
//
|
||||
// Le Go prête ce qu'il est seul à voir (handleInternalTuneContext) et le
|
||||
// démarrage et l'arrêt du moteur SANS le refus de l'optimiseur
|
||||
// (handleInternalServiceAction, « os ») : c'est la façade qui tient le verrou.
|
||||
|
||||
const facadeTuneEnv = "LOKI_TUNE"
|
||||
|
||||
// facadeTune : la façade tient-elle l'optimiseur ?
|
||||
func facadeTune() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeTuneEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadeTuneRoutes : les routes que la façade sert alors.
|
||||
var facadeTuneRoutes = map[string]bool{
|
||||
"/api/tune": true,
|
||||
"/api/tune/status": true,
|
||||
"/api/tune/cancel": true,
|
||||
"/api/tune/apply": true,
|
||||
"/api/tune/last": true,
|
||||
}
|
||||
|
||||
// handleInternalTuneContext : GET → {busy, main_active, perf}.
|
||||
//
|
||||
// - busy : ce qui interdit d'optimiser dans le processus web
|
||||
// (tunePreflight) — un benchmark, une génération, des jobs du mode code ;
|
||||
// "" sinon ;
|
||||
// - main_active : le vrai moteur tourne (engineNeedsStop) ;
|
||||
// - perf : la télémétrie, d'où l'optimiseur tire les poids d'un tour type.
|
||||
func handleInternalTuneContext(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", http.MethodGet)
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
|
||||
return
|
||||
}
|
||||
busy := ""
|
||||
switch {
|
||||
case benchRunning():
|
||||
busy = errBenchBusy.Error()
|
||||
case conv.isGenerating():
|
||||
busy = "génération en cours"
|
||||
if err := conv.busyReason(); err != nil {
|
||||
busy = err.Error()
|
||||
}
|
||||
case bgJobsRunning() > 0:
|
||||
busy = fmt.Sprintf("%d job(s) d'arrière-plan du mode code tournent encore (bash_bg) : ils fausseraient les mesures — arrête-les d'abord",
|
||||
bgJobsRunning())
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"busy": busy, "main_active": engineNeedsStop(),
|
||||
"perf": perfLog.snapshot()})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// L'optimiseur part à la façade avec LOKI_TUNE=rust (lot 28) ; le reste,
|
||||
// non. La façade tient le verrou de génération par le bail du moteur, motif
|
||||
// « tune » ; le Go lui prête ce qu'il est seul à voir et le démarrage du
|
||||
// moteur sans le refus de l'optimiseur.
|
||||
func TestFacadeTune(t *testing.T) {
|
||||
testHome(t)
|
||||
var mu sync.Mutex
|
||||
var calls []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get(internalTokenHeader) != jetonTest {
|
||||
w.WriteHeader(403)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||
mu.Unlock()
|
||||
io.WriteString(w, `{"ok":true,"de":"facade"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv(internalTokenEnv, jetonTest)
|
||||
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
|
||||
t.Setenv(facadeTuneEnv, "")
|
||||
if facadeTune() {
|
||||
t.Fatal("tenu sans LOKI_TUNE")
|
||||
}
|
||||
for p := range facadeTuneRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("sans LOKI_TUNE, %s : %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
t.Setenv(facadeTuneEnv, " RUST")
|
||||
if len(facadeTuneRoutes) != 5 {
|
||||
t.Fatalf("routes : %v", facadeTuneRoutes)
|
||||
}
|
||||
for p := range facadeTuneRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if !strings.Contains(w.Body.String(), "facade") {
|
||||
t.Fatalf("%s non relayé : %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
n := len(calls)
|
||||
mu.Unlock()
|
||||
if n != len(facadeTuneRoutes) {
|
||||
t.Fatalf("relais : %v", calls)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat("/api/bench", local)(w, httptest.NewRequest("POST", "/api/bench", nil))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("le banc d'essai reste au Go : %q", w.Body.String())
|
||||
}
|
||||
|
||||
post := func(h http.HandlerFunc, path, body string) (int, map[string]any) {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("POST", path, strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
tuneCtx := func() map[string]any {
|
||||
rec := httptest.NewRecorder()
|
||||
handleInternalTuneContext(rec, httptest.NewRequest("GET", "/internal/v1/tune/context", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("contexte : %d", rec.Code)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Au repos : rien n'interdit, la télémétrie suit.
|
||||
perfRecord(perfRec{Kind: perfMain, Complete: true, Total: 100, New: 100, Gen: 10}, nil)
|
||||
c := tuneCtx()
|
||||
if c["busy"] != "" || c["main_active"] != false {
|
||||
t.Fatalf("contexte au repos : %v", c)
|
||||
}
|
||||
if perf, _ := c["perf"].([]any); len(perf) == 0 {
|
||||
t.Fatalf("télémétrie absente : %v", c["perf"])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleInternalTuneContext(rec, httptest.NewRequest("POST", "/internal/v1/tune/context", nil))
|
||||
if rec.Code != 405 || rec.Header().Get("Allow") != "GET" {
|
||||
t.Fatalf("POST : %d", rec.Code)
|
||||
}
|
||||
|
||||
// Le bail « tune » : les autres reçoivent le refus de l'optimiseur ; ce
|
||||
// n'est pas un benchmark.
|
||||
code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{"ttl_ms":60000,"why":"tune"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("bail : %d %v", code, out)
|
||||
}
|
||||
id, _ := out["lease"].(string)
|
||||
if conv.busyReason() != errTuneBusy || benchRunning() || !conv.isGenerating() {
|
||||
t.Fatalf("bail de l'optimiseur : refus=%v bench=%v", conv.busyReason(), benchRunning())
|
||||
}
|
||||
if code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{}`); code != 409 || out["error"] != errTuneBusy.Error() {
|
||||
t.Fatalf("second bail : %d %v", code, out)
|
||||
}
|
||||
if c := tuneCtx(); c["busy"] != errTuneBusy.Error() {
|
||||
t.Fatalf("contexte pendant l'optimisation : %v", c)
|
||||
}
|
||||
// Changer de discussion ne rompt pas le bail de l'optimiseur.
|
||||
conv.loadFrom("autre", nil)
|
||||
if !conv.isGenerating() || conv.busyReason() != errTuneBusy {
|
||||
t.Fatal("le bail de l'optimiseur a sauté à la bascule de discussion")
|
||||
}
|
||||
if code, _ := post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); code != 204 {
|
||||
t.Fatalf("libération : %d", code)
|
||||
}
|
||||
if conv.isGenerating() || conv.busyReason() == errTuneBusy {
|
||||
t.Fatal("bail rendu : le verrou devait être libre")
|
||||
}
|
||||
// Un benchmark de la façade : le contexte le dit en premier.
|
||||
code, out = post(handleInternalLease, "/internal/v1/engine/lease", `{"why":"bench"}`)
|
||||
if code != 200 {
|
||||
t.Fatalf("bail du bench : %d %v", code, out)
|
||||
}
|
||||
if c := tuneCtx(); c["busy"] != errBenchBusy.Error() {
|
||||
t.Fatalf("contexte pendant un bench : %v", c)
|
||||
}
|
||||
post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+out["lease"].(string)+`"}`)
|
||||
|
||||
// Démarrage et arrêt sans le refus de l'optimiseur : la façade tient le
|
||||
// verrou.
|
||||
var guarded, osActs []string
|
||||
oldA, oldOS := internalServiceAction, internalServiceActionOS
|
||||
t.Cleanup(func() { internalServiceAction, internalServiceActionOS = oldA, oldOS })
|
||||
internalServiceAction = func(a string) error { guarded = append(guarded, a); return errTuneBusy }
|
||||
internalServiceActionOS = func(a string) error { osActs = append(osActs, a); return nil }
|
||||
for _, c := range []struct{ body, want string }{
|
||||
{`{"action":"stop","os":true}`, `{"error":"","ran":true}`},
|
||||
{`{"action":"start","os":true}`, `{"error":"","ran":true}`},
|
||||
{`{"action":"start"}`, `{"error":"` + errTuneBusy.Error() + `","ran":true}`},
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
handleInternalServiceAction(w, httptest.NewRequest("POST", "/internal/v1/service/action", strings.NewReader(c.body)))
|
||||
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != c.want {
|
||||
t.Fatalf("%s : %d %s", c.body, w.Code, got)
|
||||
}
|
||||
}
|
||||
if strings.Join(osActs, ",") != "stop,start" || strings.Join(guarded, ",") != "start" {
|
||||
t.Fatalf("actions : sans refus %v, avec %v", osActs, guarded)
|
||||
}
|
||||
|
||||
t.Setenv(internalTokenEnv, "")
|
||||
if facadeTune() {
|
||||
t.Fatal("tenu sans jeton interne")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// parity_tune_test.go — journal de référence de la partie PURE de
|
||||
// l'optimiseur (lot 28 de la migration Rust) : découpe et réécriture
|
||||
// d'EXTRA_ARGS, réglages par clé ou par drapeau, essais de chaque étape selon
|
||||
// la machine et les cases cochées, garde-fous (cœur de la ligne, dénature,
|
||||
// clés permises), diff et réécriture du preset, journal d'un essai, score et
|
||||
// départage. Rangé dans rust/loki-rs/tests/parity_tune/core.json ;
|
||||
// tests/tune_core.rs le rejoue.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTuneCore
|
||||
|
||||
const parityTuneCoreFile = "../../rust/loki-rs/tests/parity_tune/core.json"
|
||||
|
||||
func parityTuneCands(c []tuneCand) []map[string]any {
|
||||
out := []map[string]any{}
|
||||
for _, x := range c {
|
||||
out = append(out, map[string]any{"stage": x.Stage, "label": x.Label, "cfg": x.Cfg})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPariteTuneCore(t *testing.T) {
|
||||
journal := map[string]any{}
|
||||
|
||||
// --- EXTRA_ARGS ---------------------------------------------------------
|
||||
extras := []string{
|
||||
"", " ", "-b 2048", `--chat-template-file "/mes modèles/tpl.jinja" -ub 512`,
|
||||
`-ot 'blk\.(1[0-9])\.ffn_.*_exps\.=CPU' --n-cpu-moe 40`,
|
||||
`-ot "blk\.([0-9]+)\.ffn_.*_exps\.=CPU,per_layer_token_embd=CPU" --cache-type-k q8_0 --n-cpu-moe 40 ` +
|
||||
`--flash-attn on -ub 512 --chat-template-file "/a b/t.jinja" -ts 0.9,0.1 -ctv q8_0 -cmoe`,
|
||||
"-ub=1024 -t 8 -b 4096", " -ts 0.9,0.1 --flash-attn on", "-ts=1,1 -mg 0",
|
||||
"-ot exps=CPU,per_layer_token_embd=CPU -cmoe --cpu-moe -ncmoe=12",
|
||||
"-ot=per_layer_token_embd=CPU", "--override-tensor exps=CPU", "-ot", "-t", "-t 8 -t 6", "-ub",
|
||||
`"" -x`, `""x y`, `a""b`, `'a b'c "d"`, "--cache-type-k q8_0\t--flash-attn on\n-t 8",
|
||||
`"non fermé -b 4`, `x"y z"w -ub=1024`, "-bs", "--backend-sampling -t 4", "-b 512\t-ub 256",
|
||||
"-ncmoe", "--n-cpu-moe 0 --cpu-moe",
|
||||
}
|
||||
var ex []map[string]any
|
||||
for _, s := range extras {
|
||||
var spans [][]any
|
||||
for _, sp := range argSpans(s) {
|
||||
spans = append(spans, []any{sp.start, sp.end, sp.val})
|
||||
}
|
||||
dropped, removed := tuneExtraDropExperts(s)
|
||||
ex = append(ex, map[string]any{
|
||||
"in": s,
|
||||
"spans": spans,
|
||||
"split": splitArgs(s),
|
||||
"set_ub": tuneExtraSet(s, knobUBatch.flags, "2048"),
|
||||
"drop_ub": tuneExtraSet(s, knobUBatch.flags, ""),
|
||||
"set_t": tuneExtraSet(s, knobThreads.flags, "7"),
|
||||
"set_t_space": tuneExtraSet(s, knobThreads.flags, "a b"),
|
||||
"set_b": tuneExtraSet(s, knobBatch.flags, "4096"),
|
||||
"drop_ts": tuneExtraDrop(s, 1, "-ts", "--tensor-split"),
|
||||
"drop_cmoe": tuneExtraDrop(s, 0, "-cmoe", "--cpu-moe"),
|
||||
"add_bs": tuneExtraAddSwitch(s, "--backend-sampling"),
|
||||
"drop_experts": dropped,
|
||||
"removed": removed,
|
||||
"core": tuneCoreArgs(splitArgs(s), false),
|
||||
"core_optin": tuneCoreArgs(splitArgs(s), true),
|
||||
})
|
||||
}
|
||||
journal["extra"] = ex
|
||||
|
||||
// --- réglages ------------------------------------------------------------
|
||||
cfgs := []map[string]string{
|
||||
{},
|
||||
{"UBATCH": "1024", "BATCH": "4096", "THREADS": "0", "FIT_TARGET": "1536", "SPEC_N_MAX": "2"},
|
||||
{"EXTRA_ARGS": "-ub 512 -t 8", "UBATCH": "1024"},
|
||||
{"EXTRA_ARGS": "-ub=256 --threads 6 -b 1024 -fitt 2048 --spec-draft-n-max 5"},
|
||||
{"EXTRA_ARGS": "-ub 512", "UBATCH": " 128 "},
|
||||
{"EXTRA_ARGS": "-t"},
|
||||
tuneRichCfg(),
|
||||
}
|
||||
knobs := map[string]tuneKnob{"BATCH": knobBatch, "UBATCH": knobUBatch, "THREADS": knobThreads,
|
||||
"FIT_TARGET": knobFitTarget, "SPEC_N_MAX": knobSpecN}
|
||||
var kn []map[string]any
|
||||
for _, c := range cfgs {
|
||||
get, set, unset := map[string]string{}, map[string]any{}, map[string]any{}
|
||||
for name, k := range knobs {
|
||||
get[name] = k.get(c)
|
||||
set[name] = k.set(c, "99")
|
||||
unset[name] = k.set(c, "")
|
||||
}
|
||||
kn = append(kn, map[string]any{"cfg": c, "get": get, "set": set, "unset": unset})
|
||||
}
|
||||
journal["knobs"] = kn
|
||||
|
||||
// --- essais ---------------------------------------------------------------
|
||||
type envCase struct {
|
||||
name string
|
||||
env tuneEnv
|
||||
}
|
||||
rich := tuneRichEnv()
|
||||
envs := []envCase{
|
||||
{"riche", rich},
|
||||
{"sans placement", func() tuneEnv { e := tuneRichEnv(); e.Placement = false; return e }()},
|
||||
{"sans --fit", func() tuneEnv {
|
||||
e := tuneRichEnv()
|
||||
e.Help = strings.ReplaceAll(tuneTestHelp, "--fit", "--nofit")
|
||||
return e
|
||||
}()},
|
||||
{"sans --fit-target", func() tuneEnv {
|
||||
e := tuneRichEnv()
|
||||
e.Help = strings.ReplaceAll(tuneTestHelp, "--fit-target", "--fit-x")
|
||||
return e
|
||||
}()},
|
||||
{"sans nvidia-smi", func() tuneEnv { e := tuneRichEnv(); e.SMI = false; return e }()},
|
||||
{"sans opt-in", func() tuneEnv { e := tuneRichEnv(); e.OptIn = false; return e }()},
|
||||
{"une carte", func() tuneEnv { e := tuneRichEnv(); e.GPUs = 1; return e }()},
|
||||
{"trois cartes", func() tuneEnv { e := tuneRichEnv(); e.GPUs = 3; return e }()},
|
||||
{"cœurs inconnus", func() tuneEnv { e := tuneRichEnv(); e.PhysCores = 0; return e }()},
|
||||
{"deux cœurs", func() tuneEnv { e := tuneRichEnv(); e.PhysCores = 2; return e }()},
|
||||
{"environnement posé", func() tuneEnv {
|
||||
e := tuneRichEnv()
|
||||
e.UserEnv = map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "64", "GGML_CUDA_GRAPH_OPT": "1"}
|
||||
e.QueuesEnv = true
|
||||
e.ArgEnv = map[string]string{"LLAMA_ARG_BACKEND_SAMPLING": "1"}
|
||||
return e
|
||||
}()},
|
||||
{"dense deux cartes", tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 10 << 30, SMI: true,
|
||||
PhysCores: 8, Placement: true, OptIn: true}},
|
||||
{"modèle plus gros que la VRAM", tuneEnv{Help: tuneTestHelp, GPUs: 1, VRAMMiB: 8 << 10, ModelBytes: 20 << 30, SMI: true,
|
||||
PhysCores: 8}},
|
||||
{"moteur ancien", tuneEnv{GPUs: 1, SMI: true, PhysCores: 4, Placement: true, OptIn: true}},
|
||||
}
|
||||
candCfgs := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
}{
|
||||
{"riche", tuneRichCfg()},
|
||||
{"simple", map[string]string{"MODEL": "m.gguf", "CTX": "32768"}},
|
||||
{"contexte non chiffré", map[string]string{"MODEL": "m.gguf", "EXTRA_ARGS": "-ot exps=CPU"}},
|
||||
{"split tensor", map[string]string{"MODEL": "m.gguf", "CTX": "32768", "SPLIT_MODE": "tensor", "EXTRA_ARGS": "-ts 1,1"}},
|
||||
{"lots posés", map[string]string{"MODEL": "m.gguf", "CTX": "8192", "UBATCH": "1024", "BATCH": "2048",
|
||||
"OP_OFFLOAD_MIN_BATCH": "128", "CUDA_LAUNCH_QUEUES": "4X", "THREADS": "7", "FIT_TARGET": "1536"}},
|
||||
{"spéculation", map[string]string{"MODEL": "m.gguf", "CTX": "8192", "SPEC": "draft", "SPEC_N_MAX": "4",
|
||||
"CUDA_GRAPH_OPT": "On", "EXTRA_ARGS": "--n-cpu-moe 10 -bs"}},
|
||||
{"ngram", map[string]string{"MODEL": "m.gguf", "CTX": "8192", "SPEC": "ngram"}},
|
||||
{"cpu-moe", map[string]string{"MODEL": "m.gguf", "CTX": "16384", "EXTRA_ARGS": "-cmoe -t 0"}},
|
||||
}
|
||||
var cs []map[string]any
|
||||
for _, cc := range candCfgs {
|
||||
for _, ec := range envs {
|
||||
stages := map[string]any{}
|
||||
for _, st := range tuneStages {
|
||||
c, why := st.Gen(cc.cfg, ec.env)
|
||||
stages[st.Name] = map[string]any{"why": why, "cands": parityTuneCands(c)}
|
||||
}
|
||||
e := ec.env
|
||||
cs = append(cs, map[string]any{"cfg_name": cc.name, "env_name": ec.name, "cfg": cc.cfg,
|
||||
"env": map[string]any{"help": e.Help, "gpus": e.GPUs, "vram_mib": e.VRAMMiB, "model_bytes": e.ModelBytes,
|
||||
"moe": e.MoE, "phys_cores": e.PhysCores, "smi": e.SMI, "arg_env": e.ArgEnv, "user_env": e.UserEnv,
|
||||
"queues_env": e.QueuesEnv, "placement": e.Placement, "opt_in": e.OptIn},
|
||||
"weights_on_cpu": tuneWeightsOnCPU(cc.cfg, ec.env),
|
||||
"stages": stages})
|
||||
}
|
||||
}
|
||||
journal["cands"] = cs
|
||||
var sw []map[string]any
|
||||
for _, name := range []string{"placement", "marges", "lots", "délestage", "threads", "files CUDA", "opt-in"} {
|
||||
for _, only := range [][]string{nil, {"lots"}, {"Délestage"}, {"files"}, {"FILES CUDA"}, {"marges-x"}, {" threads "}, {"opt"}, {"x"}} {
|
||||
sw = append(sw, map[string]any{"name": name, "only": only, "wanted": tuneStageWanted(name, only)})
|
||||
}
|
||||
}
|
||||
journal["stage_wanted"] = sw
|
||||
|
||||
// --- garde-fous ------------------------------------------------------------
|
||||
base := tuneLaunch{Args: []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "2048", "-ub", "512",
|
||||
"--host", "127.0.0.1", "--port", "41000", "--parallel", "1", "-ctk", "q8_0", "-ot", "exps=CPU,per_layer_token_embd=CPU",
|
||||
"--cache-ram", "8192", "--flash-attn", "on"},
|
||||
Env: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_SCALE_LAUNCH_QUEUES": "4x"}}
|
||||
ok := tuneLaunch{Args: []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "4096", "-ub", "2048",
|
||||
"--host", "127.0.0.1", "--port", "41001", "--parallel", "1", "-ctk", "q8_0", "-ot", "per_layer_token_embd=CPU",
|
||||
"--cache-ram", "12000", "--flash-attn", "on", "-t", "7"},
|
||||
Env: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}}
|
||||
clone := func(l tuneLaunch) tuneLaunch {
|
||||
c := tuneLaunch{Args: append([]string(nil), l.Args...), Env: map[string]string{}}
|
||||
for k, v := range l.Env {
|
||||
c.Env[k] = v
|
||||
}
|
||||
return c
|
||||
}
|
||||
var dn []map[string]any
|
||||
add := func(name string, b, c tuneLaunch) {
|
||||
dn = append(dn, map[string]any{"name": name, "base": b, "cand": c,
|
||||
"denature": tuneDenature(b, c, false), "denature_optin": tuneDenature(b, c, true),
|
||||
"same_key": tuneLaunchKey(b) == tuneLaunchKey(c)})
|
||||
}
|
||||
add("réglages permis", base, ok)
|
||||
add("identique", base, clone(base))
|
||||
muts := map[string]func(l *tuneLaunch){
|
||||
"contexte": func(l *tuneLaunch) { l.Args[4] = "32768" },
|
||||
"cache kv": func(l *tuneLaunch) { l.Args[16] = "f16" },
|
||||
"ot": func(l *tuneLaunch) { l.Args[18] = "exps=CPU" },
|
||||
"fa": func(l *tuneLaunch) { l.Args[len(l.Args)-3] = "off" },
|
||||
"slots": func(l *tuneLaunch) { l.Args[14] = "2" },
|
||||
"cuda": func(l *tuneLaunch) { l.Env["CUDA_VISIBLE_DEVICES"] = "0,1" },
|
||||
"graphes": func(l *tuneLaunch) { l.Env["GGML_CUDA_GRAPH_OPT"] = "1" },
|
||||
"spéculation": func(l *tuneLaunch) { l.Args = append(l.Args, "--spec-draft-n-max", "4") },
|
||||
"modèle ébauche": func(l *tuneLaunch) {
|
||||
l.Args = append(l.Args, "-md", "/d.gguf")
|
||||
},
|
||||
"argument de plus": func(l *tuneLaunch) { l.Args = append(l.Args, "--jinja") },
|
||||
"argument de moins": func(l *tuneLaunch) {
|
||||
l.Args = l.Args[:len(l.Args)-2]
|
||||
},
|
||||
"port et clé": func(l *tuneLaunch) {
|
||||
l.Args = append(l.Args, "--api-key", "s3cret", "--slot-save-path", "/tmp/x", "--port=9")
|
||||
},
|
||||
"cache-ram": func(l *tuneLaunch) { l.Args = append(l.Args, "-cram", "4096") },
|
||||
}
|
||||
names := make([]string, 0, len(muts))
|
||||
for n := range muts {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
c := clone(ok)
|
||||
muts[name](&c)
|
||||
add(name, base, c)
|
||||
}
|
||||
journal["denature"] = dn
|
||||
|
||||
rc := tuneRichCfg()
|
||||
var cc []map[string]any
|
||||
check := func(name string, b, c map[string]string) {
|
||||
e1, e2 := tuneCfgCheck(b, c, false), tuneCfgCheck(b, c, true)
|
||||
s1, s2 := "", ""
|
||||
if e1 != nil {
|
||||
s1 = e1.Error()
|
||||
}
|
||||
if e2 != nil {
|
||||
s2 = e2.Error()
|
||||
}
|
||||
cc = append(cc, map[string]any{"name": name, "base": b, "cand": c, "check": s1, "check_optin": s2,
|
||||
"diff": tuneDiff(b, c)})
|
||||
}
|
||||
check("identique", rc, copyCfg(rc))
|
||||
check("lots", rc, setKey(setKey(rc, "UBATCH", "2048"), "BATCH", "4096"))
|
||||
check("contexte", rc, setKey(rc, "CTX", "32768"))
|
||||
check("spec", rc, setKey(rc, "SPEC", "auto"))
|
||||
check("graphes", rc, setKey(rc, "CUDA_GRAPH_OPT", "on"))
|
||||
check("retrait", rc, setKey(rc, "TEMP", ""))
|
||||
check("extra permis", rc, knobThreads.set(rc, "4"))
|
||||
check("extra interdit", rc, setKey(rc, "EXTRA_ARGS", strings.Replace(rc["EXTRA_ARGS"], "q8_0", "f16", 1)))
|
||||
check("extra placement", rc, setKey(rc, "EXTRA_ARGS", func() string { s, _ := tuneExtraDropExperts(rc["EXTRA_ARGS"]); return s }()))
|
||||
check("spéculation dans extra", rc, setKey(rc, "EXTRA_ARGS", rc["EXTRA_ARGS"]+" --spec-draft-n-max 4"))
|
||||
check("extra vide", map[string]string{"EXTRA_ARGS": "-t 8"}, map[string]string{})
|
||||
journal["cfg_check"] = cc
|
||||
|
||||
content := "# NAME=Gros MoE\n# commentaire\nMODEL=big.gguf\nCTX=65536\nUBATCH=512\n" +
|
||||
`EXTRA_ARGS="--cache-type-k q8_0 --flash-attn on -t 8"` + "\n"
|
||||
pb := parseEnv(content)
|
||||
var pp []map[string]any
|
||||
patch := func(name string, win map[string]string) {
|
||||
out, err := tunePatchPreset(content, pb, win)
|
||||
e := ""
|
||||
if err != nil {
|
||||
e = err.Error()
|
||||
}
|
||||
pp = append(pp, map[string]any{"name": name, "win": win, "out": out, "error": e})
|
||||
}
|
||||
patch("threads retirés, lots", setKey(setKey(knobThreads.set(pb, ""), "UBATCH", "2048"), "BATCH", "4096"))
|
||||
patch("contexte", setKey(pb, "CTX", "32768"))
|
||||
patch("rien", copyCfg(pb))
|
||||
patch("opt-in", setKey(pb, "SPEC", "auto"))
|
||||
patch("retrait d'EXTRA_ARGS", setKey(pb, "EXTRA_ARGS", ""))
|
||||
journal["patch"] = struct {
|
||||
Content string `json:"content"`
|
||||
Cases []map[string]any `json:"cases"`
|
||||
}{content, pp}
|
||||
|
||||
// --- journal d'un essai ------------------------------------------------------
|
||||
logs := []string{
|
||||
"load_tensors: offloaded 40/49 layers to GPU\n" +
|
||||
"llama_kv_cache: size = 1024.00 MiB ( 8192 cells, 32 layers, 1/1 seqs), K (q8_0): 512.00 MiB, V (q8_0): 512.00 MiB\n" +
|
||||
"load_tensors: offloaded 49/49 layers to GPU\n",
|
||||
"rien",
|
||||
"",
|
||||
"K (F16): 1 MiB, V (f16): 1 MiB\nK (q4_0): 1, V (q8_0): 2\nK (f16): x V (f16): y",
|
||||
"ggml_backend_cuda_buffer_type_alloc_buffer: cudaMalloc failed: out of memory",
|
||||
"common_fit: failed to fit model\n",
|
||||
"llama_model_load: error loading model: bad magic\n",
|
||||
"ligne 1\nderniere ligne de journal \n\n",
|
||||
"\n \n",
|
||||
"x\n" + strings.Repeat("é", 100),
|
||||
"x\n" + strings.Repeat("a", 159) + "é" + strings.Repeat("b", 20),
|
||||
}
|
||||
var lf []map[string]any
|
||||
for _, l := range logs {
|
||||
off, total, kv := tuneLogFacts(l)
|
||||
lf = append(lf, map[string]any{"log": l, "offloaded": off, "total": total, "kv": kv, "failure": tuneLoadFailure(l)})
|
||||
}
|
||||
journal["logs"] = lf
|
||||
|
||||
// --- score et départage ------------------------------------------------------
|
||||
intp := func(n int) *int { return &n }
|
||||
var recSets []map[string]any
|
||||
weights := func(name string, recs []perfRec) {
|
||||
recSets = append(recSets, map[string]any{"name": name, "recs": recs, "weights": tuneWeightsFrom(recs)})
|
||||
}
|
||||
weights("vide", nil)
|
||||
var few []perfRec
|
||||
for i := 0; i < 19; i++ {
|
||||
few = append(few, perfRec{Kind: perfMain, Complete: true, New: 1000 + i, Gen: 300})
|
||||
}
|
||||
weights("trop peu", few)
|
||||
var many []perfRec
|
||||
for i := 0; i < 30; i++ {
|
||||
r := perfRec{Kind: perfMain, Complete: true, New: 500 + 37*i, Gen: 100 + 13*i, Total: 4000 + 100*i}
|
||||
if i%3 == 0 {
|
||||
r.Cached = intp(100)
|
||||
} else {
|
||||
r.Cached = intp(r.Total - 200)
|
||||
}
|
||||
many = append(many, r)
|
||||
}
|
||||
many = append(many, perfRec{Kind: perfBench, Complete: true, New: 99999, Gen: 99999},
|
||||
perfRec{Kind: perfMain, Complete: false, New: 99999, Gen: 99999})
|
||||
weights("télémétrie", many)
|
||||
var bounds []perfRec
|
||||
for i := 0; i < 21; i++ {
|
||||
bounds = append(bounds, perfRec{Kind: perfTask, Complete: true, New: 50, Gen: 9000, Total: 100, Cached: intp(0)})
|
||||
}
|
||||
weights("bornes", bounds)
|
||||
var even []perfRec
|
||||
for i := 0; i < 20; i++ {
|
||||
even = append(even, perfRec{Kind: perfMain, Complete: true, New: 1000 + 10*i, Gen: 200 + i, Total: 2048, Cached: intp(204)})
|
||||
}
|
||||
weights("pair, froid connu", even)
|
||||
journal["weights"] = recSets
|
||||
|
||||
w := tuneWeights{K: 2000, G: 600, FCold: 0.05}
|
||||
deep := func() *benchResult {
|
||||
return &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50, Depth: &benchDepth{Target: 32768,
|
||||
Cold: &benchTurn{PromptPerSecond: 800},
|
||||
DecodePerSec: 40, CachedPerSec: 999,
|
||||
Turns: []benchTurn{{New: 2100, Cached: 30000, Expected: 30048, PromptMs: 2000},
|
||||
{New: 2100, Cached: 32300, Expected: 32348, PromptMs: 2000}}}}
|
||||
}
|
||||
var runs []map[string]any
|
||||
score := func(name string, r *benchResult) {
|
||||
run, err := tuneRunFrom(r, w)
|
||||
e := ""
|
||||
if err != nil {
|
||||
e = err.Error()
|
||||
}
|
||||
runs = append(runs, map[string]any{"name": name, "bench": r, "run": run, "error": e})
|
||||
}
|
||||
score("complet", deep())
|
||||
score("vide", nil)
|
||||
score("sans decode", &benchResult{PromptPerSecond: 1000})
|
||||
score("rapide", &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50})
|
||||
score("profondeur sautée", &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50, Depth: &benchDepth{Skipped: "petit"}})
|
||||
score("partiel", func() *benchResult { r := deep(); r.Depth.Partial = "contexte plein"; return r }())
|
||||
score("sans froid", func() *benchResult { r := deep(); r.Depth.Cold = nil; return r }())
|
||||
score("froid nul", func() *benchResult { r := deep(); r.Depth.Cold.PromptPerSecond = 0; return r }())
|
||||
score("sans tours", func() *benchResult { r := deep(); r.Depth.Turns = nil; return r }())
|
||||
score("sans decode à D", func() *benchResult { r := deep(); r.Depth.DecodePerSec = 0; return r }())
|
||||
score("cache inconnu", func() *benchResult { r := deep(); r.Depth.Turns[1].Cached = -1; return r }())
|
||||
score("cache inconnu, rien d'autre", func() *benchResult {
|
||||
r := deep()
|
||||
r.Depth.Turns[1].Cached = -1
|
||||
r.Depth.CachedPerSec = 0
|
||||
return r
|
||||
}())
|
||||
score("rien de neuf", func() *benchResult {
|
||||
r := deep()
|
||||
for i := range r.Depth.Turns {
|
||||
r.Depth.Turns[i].New = 48
|
||||
}
|
||||
return r
|
||||
}())
|
||||
journal["runs"] = runs
|
||||
|
||||
var wn []map[string]any
|
||||
for _, c := range [][2][]float64{
|
||||
{{20, 20.4}, {19.7, 19.8}}, {{20, 20.4}, {18}}, {{20, 20.4}, {18, 18.1}}, {{18, 22}, {18.5, 18.6}},
|
||||
{{}, {1, 2}}, {{10}, {}}, {{10, 10}, {9.6, 9.6}}, {{10, 10}, {9.7, 9.7}}, {{0}, {1, 1}}, {{10, 12}, {8, 8.2, 8.1}},
|
||||
} {
|
||||
gain, need, ok := tuneWins(c[0], c[1])
|
||||
wn = append(wn, map[string]any{"base": c[0], "cand": c[1], "gain": gain, "need": need, "wins": ok,
|
||||
"spread_base": tuneSpread(c[0]), "mean_cand": tuneMean(c[1])})
|
||||
}
|
||||
journal["wins"] = wn
|
||||
var wr []map[string]any
|
||||
for _, c := range []struct {
|
||||
base []float64
|
||||
first float64
|
||||
}{{[]float64{20, 20.4}, 19}, {[]float64{20, 20.4}, 19.8}, {[]float64{20}, 19.39}, {[]float64{20}, 19.41}, {nil, 1}} {
|
||||
wr = append(wr, map[string]any{"base": c.base, "first": c.first, "worth": tuneWorthRepeat(c.base, c.first)})
|
||||
}
|
||||
journal["worth_repeat"] = wr
|
||||
|
||||
var hr []map[string]any
|
||||
for _, c := range []struct {
|
||||
gpus []gpuStat
|
||||
visible []int
|
||||
}{
|
||||
{nil, nil},
|
||||
{[]gpuStat{{Total: 24576, Used: 20000}, {Total: 12288, Used: 1000}}, nil},
|
||||
{[]gpuStat{{Total: 24576, Used: 20000}, {Total: 12288, Used: 1000}}, []int{1}},
|
||||
{[]gpuStat{{Total: 0, Used: 0}, {Total: 8192, Used: 9000}}, nil},
|
||||
{[]gpuStat{{Total: 8192, Used: 100}}, []int{3}},
|
||||
} {
|
||||
free, ok := tuneHeadroom(c.gpus, c.visible)
|
||||
var g [][2]int
|
||||
for _, x := range c.gpus {
|
||||
g = append(g, [2]int{x.Total, x.Used})
|
||||
}
|
||||
hr = append(hr, map[string]any{"gpus": g, "visible": c.visible, "free": free, "ok": ok})
|
||||
}
|
||||
journal["headroom"] = hr
|
||||
var vg []map[string]any
|
||||
for _, s := range []string{"", "0", "1,0", " 2 , 3 ", "GPU-abc", "0,,1", "+1,-2"} {
|
||||
vg = append(vg, map[string]any{"in": s, "out": tuneVisibleGPUs(map[string]string{"CUDA_VISIBLE_DEVICES": s})})
|
||||
}
|
||||
journal["visible"] = vg
|
||||
var fa []map[string]any
|
||||
for _, a := range [][]string{
|
||||
{"/b", "--host", "0.0.0.0", "--port", "8080", "-m", "x"},
|
||||
{"/b", "--host=::", "--port=1", "--port", "2", "--host"},
|
||||
{"/b", "--hostname", "x", "-p", "3"},
|
||||
} {
|
||||
fa = append(fa, map[string]any{"in": a, "out": tuneForceAddr(a, "127.0.0.1", "41234")})
|
||||
}
|
||||
journal["force_addr"] = fa
|
||||
|
||||
// --- configuration d'un essai --------------------------------------------------
|
||||
var tc []map[string]any
|
||||
for _, c := range []map[string]string{
|
||||
{"MODEL": "m.gguf", "EXTRA_ARGS": `-ot "a b" -t 8`, "PORT": "41000"},
|
||||
{"MODEL": "x\ny"},
|
||||
{"NAME": "é ü", "A": " b "},
|
||||
{"A": `"q"`, "B": "c'd", "C": "#h"},
|
||||
} {
|
||||
dir := t.TempDir()
|
||||
err := writeTuneTrialConfig(dir, c)
|
||||
e, text := "", ""
|
||||
if err != nil {
|
||||
e = err.Error()
|
||||
} else {
|
||||
b, _ := os.ReadFile(dir + "/config.env")
|
||||
text = string(b)
|
||||
}
|
||||
tc = append(tc, map[string]any{"cfg": c, "error": e, "text": text})
|
||||
}
|
||||
journal["trial_config"] = tc
|
||||
|
||||
// --- Verrou : contenu du fichier, clé de prise, binaires -----------------
|
||||
owners := []tuneOwner{
|
||||
{Owner: tuneProc{PID: 42}, Via: "web", Phase: "essais", Since: 1700000000},
|
||||
{Owner: tuneProc{PID: 42, Start: "b0c1/123", Exes: []string{"/usr/bin/loki-rs"}}, Via: "apply",
|
||||
Phase: "application", Since: 1700000000, Nonce: 1700000000123456789,
|
||||
Trial: &tuneProc{PID: 43, Start: "b0c1/456", Exes: []string{"/usr/bin/loki", "/opt/llama-server"}},
|
||||
MainWasActive: true, Backup: "/home/l/tune/backup/Dense-1.env", PresetID: "Dense", PresetName: "Dense"},
|
||||
}
|
||||
lk := []map[string]any{}
|
||||
for _, o := range owners {
|
||||
b, _ := json.Marshal(o)
|
||||
lk = append(lk, map[string]any{"json": string(b), "key": tuneHeldKey(o)})
|
||||
}
|
||||
journal["lock_owners"] = lk
|
||||
se := []map[string]any{}
|
||||
for _, e := range [][2]string{
|
||||
{"/usr/local/bin/loki", "/usr/local/bin/loki (deleted)"}, {"/usr/local/bin/loki", "/usr/bin/llama-server"},
|
||||
{`C:\Loki\loki.exe`, "LOKI"}, {"", "loki"}, {"/a/llama-server", "llama-server"}, {"/a/b/", "b"},
|
||||
} {
|
||||
se = append(se, map[string]any{"a": e[0], "b": e[1], "same": sameExe(e[0], e[1])})
|
||||
}
|
||||
journal["same_exe"] = se
|
||||
|
||||
// --- Résultat tel qu'enregistré et montré --------------------------------
|
||||
run := tuneRun{TurnSec: 3.25, ColdPP: 812.5, CachedPP: 1500, TG: 42.75, ProsePP: 2000, ProseTG: 45.5, Depth: 8192}
|
||||
results := []tuneResult{{}, {
|
||||
PresetID: "Dense", PresetName: "Dense", BaseFP: "abc", PresetFP: "def", Engine: "b7000-x", EngineBuild: 7000,
|
||||
Weights: tuneWeights{K: 2000, G: 600, FCold: 0.05, From: "défaut"}, Depth: 8192, NCtx: 16384, Slots: 1,
|
||||
Baseline: tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "mesuré",
|
||||
Runs: []tuneRun{run, {TurnSec: 3.5, ProsePP: 1900, ProseTG: 44}}, TurnSec: 3.375, Headroom: -1, Sec: 61.5,
|
||||
Offloaded: "33/33"},
|
||||
Trials: []tuneTrial{
|
||||
{Stage: "lots", Label: "UBATCH=512 BATCH=1024", Status: "retenu", Why: "−7.3 % par tour (seuil 3.0 %)",
|
||||
Runs: []tuneRun{run}, TurnSec: 3.125, Gain: 0.0734, Headroom: 512, Changes: []string{"UBATCH 2048 → 512"}, Sec: 30},
|
||||
{Stage: "lots", Label: "UBATCH=2048 BATCH=4096", Status: "écarté", Why: "dénature : x", Headroom: -1},
|
||||
},
|
||||
Best: "UBATCH=512 BATCH=1024", Gain: 0.0734, Set: map[string]string{"UBATCH": "512", "BATCH": "1024"},
|
||||
Old: map[string]string{"UBATCH": "2048", "BATCH": ""}, Changes: []string{"UBATCH 2048 → 512", "BATCH + 1024"},
|
||||
Placement: true, Partial: "annulé", Skipped: []string{"marges : une seule carte"}, Notes: []string{"n"},
|
||||
OptIn: true, At: 1700000000,
|
||||
}}
|
||||
rs := []string{}
|
||||
for _, r := range results {
|
||||
b, _ := json.Marshal(r)
|
||||
rs = append(rs, string(b))
|
||||
}
|
||||
journal["results"] = rs
|
||||
|
||||
// --- Après application : la configuration est-elle l'essai retenu ? ------
|
||||
ab := map[string]string{"MODEL": "m.gguf", "UBATCH": "512", "EXTRA_ARGS": "-t 8"}
|
||||
ares := &tuneResult{BaseFP: configFingerprint(ab), Set: map[string]string{"UBATCH": "2048", "EXTRA_ARGS": ""},
|
||||
Old: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-t 8"}}
|
||||
ap := []map[string]any{}
|
||||
for _, now := range []map[string]string{
|
||||
{"MODEL": "m.gguf", "UBATCH": "2048"}, {"MODEL": "m.gguf", "UBATCH": "1024"},
|
||||
{"MODEL": "autre.gguf", "UBATCH": "2048"}, {"MODEL": "m.gguf", "UBATCH": "2048", "EXTRA_ARGS": "-t 4"},
|
||||
{"MODEL": "m.gguf", "UBATCH": "2048", "PORT": "9"},
|
||||
} {
|
||||
ap = append(ap, map[string]any{"now": now, "why": tuneAppliedMismatch(now, ares)})
|
||||
}
|
||||
journal["applied"] = map[string]any{"base_fp": ares.BaseFP, "set": ares.Set, "old": ares.Old, "cases": ap}
|
||||
|
||||
got, err := parityJSON(journal)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll("../../rust/loki-rs/tests/parity_tune", 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityTuneCoreFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityTuneCoreFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if string(got) != string(want) {
|
||||
t.Errorf("l'optimiseur ne produit plus le journal rangé dans %s.\n"+
|
||||
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTuneCore,\n"+
|
||||
"puis cargo test --test tune_core dit si le port Rust suit.", parityTuneCoreFile)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,8 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalSideJob(w, r)
|
||||
case "/internal/v1/bench/perf":
|
||||
handleInternalBenchPerf(w, r)
|
||||
case "/internal/v1/tune/context":
|
||||
handleInternalTuneContext(w, r)
|
||||
case "/internal/v1/tasks/run":
|
||||
handleInternalTaskRun(w, r)
|
||||
case "/internal/v1/push/send":
|
||||
|
||||
@@ -44,9 +44,11 @@ type extLeaseState struct {
|
||||
stop bool
|
||||
ttl time.Duration
|
||||
timer *time.Timer
|
||||
// bench : le bail d'un benchmark de la façade (LOKI_BENCH=rust) — les
|
||||
// autres reçoivent errBenchBusy, et changer de discussion ne le rompt pas.
|
||||
bench bool
|
||||
// why : le motif d'un bail de mesure de la façade — errBenchBusy pour un
|
||||
// benchmark (LOKI_BENCH=rust), errTuneBusy pour l'optimiseur
|
||||
// (LOKI_TUNE=rust). Les autres reçoivent ce refus, et changer de
|
||||
// discussion ne rompt pas le bail. nil : un tour ordinaire.
|
||||
why error
|
||||
}
|
||||
|
||||
// extLeaseClearLocked oublie le bail de la façade. c.mu détenu.
|
||||
@@ -57,7 +59,7 @@ var benchLeaseHeld atomic.Bool
|
||||
|
||||
func (c *Conversation) extLeaseClearLocked() {
|
||||
if c.extLease != nil {
|
||||
if c.extLease.bench {
|
||||
if c.extLease.why == errBenchBusy {
|
||||
benchLeaseHeld.Store(false)
|
||||
}
|
||||
c.extLease.timer.Stop()
|
||||
@@ -65,8 +67,9 @@ func (c *Conversation) extLeaseClearLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
// extLeaseAcquire prend le verrou de génération pour la façade.
|
||||
func (c *Conversation) extLeaseAcquire(ttl time.Duration, bench bool) (string, error) {
|
||||
// extLeaseAcquire prend le verrou de génération pour la façade ; why : voir
|
||||
// extLeaseState.
|
||||
func (c *Conversation) extLeaseAcquire(ttl time.Duration, why error) (string, error) {
|
||||
if err := tuneGuard(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -79,12 +82,12 @@ func (c *Conversation) extLeaseAcquire(ttl time.Duration, bench bool) (string, e
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
st := &extLeaseState{id: fmt.Sprintf("%x", b), ttl: ttl, bench: bench}
|
||||
st := &extLeaseState{id: fmt.Sprintf("%x", b), ttl: ttl, why: why}
|
||||
st.timer = time.AfterFunc(ttl, func() { c.extLeaseExpire(st) })
|
||||
c.Generating = true
|
||||
if bench {
|
||||
c.benching, c.leaseWhy = true, errBenchBusy
|
||||
benchLeaseHeld.Store(true)
|
||||
if why != nil {
|
||||
c.benching, c.leaseWhy = true, why
|
||||
benchLeaseHeld.Store(why == errBenchBusy)
|
||||
}
|
||||
c.cancel = func() {
|
||||
c.mu.Lock()
|
||||
@@ -104,7 +107,7 @@ func (c *Conversation) extLeaseExpire(st *extLeaseState) {
|
||||
}
|
||||
c.extLease = nil
|
||||
c.Generating, c.cancel = false, nil
|
||||
if st.bench {
|
||||
if st.why != nil {
|
||||
c.benching, c.leaseWhy = false, nil
|
||||
benchLeaseHeld.Store(false)
|
||||
}
|
||||
@@ -130,7 +133,7 @@ func (c *Conversation) extLeaseRelease(id string) {
|
||||
if c.extLease == nil || c.extLease.id != id {
|
||||
return
|
||||
}
|
||||
if c.extLease.bench {
|
||||
if c.extLease.why != nil {
|
||||
c.benching, c.leaseWhy = false, nil
|
||||
}
|
||||
c.extLeaseClearLocked()
|
||||
@@ -161,7 +164,7 @@ func readLeaseBody(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
func handleInternalLease(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TTLms int64 `json:"ttl_ms"`
|
||||
Why string `json:"why"` // "bench" : le bail d'un benchmark
|
||||
Why string `json:"why"` // "bench" : un benchmark ; "tune" : l'optimiseur
|
||||
}
|
||||
if !readLeaseBody(w, r, &in) {
|
||||
return
|
||||
@@ -170,7 +173,14 @@ func handleInternalLease(w http.ResponseWriter, r *http.Request) {
|
||||
if in.TTLms > 0 {
|
||||
ttl = min(max(time.Duration(in.TTLms)*time.Millisecond, extLeaseMinTTL), extLeaseMaxTTL)
|
||||
}
|
||||
id, err := conv.extLeaseAcquire(ttl, in.Why == "bench")
|
||||
var why error
|
||||
switch in.Why {
|
||||
case "bench":
|
||||
why = errBenchBusy
|
||||
case "tune":
|
||||
why = errTuneBusy
|
||||
}
|
||||
id, err := conv.extLeaseAcquire(ttl, why)
|
||||
if err != nil {
|
||||
if err == ErrBusy {
|
||||
err = conv.busyReason() // dit QUI occupe le moteur
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestBailInterneVerrouPartage(t *testing.T) {
|
||||
func TestBailInterneNeLibereQueLeSien(t *testing.T) {
|
||||
testHome(t)
|
||||
libérerVerrou(t)
|
||||
id, err := conv.extLeaseAcquire(time.Minute, false)
|
||||
id, err := conv.extLeaseAcquire(time.Minute, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func TestBailInterneNeLibereQueLeSien(t *testing.T) {
|
||||
func TestBailInterneExpire(t *testing.T) {
|
||||
testHome(t)
|
||||
libérerVerrou(t)
|
||||
id, err := conv.extLeaseAcquire(50*time.Millisecond, false)
|
||||
id, err := conv.extLeaseAcquire(50*time.Millisecond, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ ring = { version = "0.17.14", optional = true }
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] }
|
||||
rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true }
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = { version = "1.0.154", features = ["raw_value"] }
|
||||
serde_json = { version = "1.0.154", features = ["raw_value", "float_roundtrip"] }
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.1"
|
||||
tar = { version = "0.4.46", default-features = false }
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct Ctx {
|
||||
pub tasks: Option<std::sync::Arc<crate::tasks::Tasks>>,
|
||||
/// Banc d'essai tenu par la façade (`LOKI_BENCH=rust`).
|
||||
pub bench: Option<std::sync::Arc<crate::bench::job::Bench>>,
|
||||
/// Optimiseur tenu par la façade (`LOKI_TUNE=rust`).
|
||||
pub tune: Option<std::sync::Arc<crate::tune::job::Tune>>,
|
||||
/// Jeton de l'API interne : une requête qui le porte vient du Go.
|
||||
pub internal_token: String,
|
||||
}
|
||||
@@ -93,6 +95,7 @@ impl Ctx {
|
||||
|| self.strata.is_some()
|
||||
|| self.tasks.is_some()
|
||||
|| self.bench.is_some()
|
||||
|| self.tune.is_some()
|
||||
}
|
||||
|
||||
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
|
||||
|
||||
@@ -481,7 +481,7 @@ async fn load_last(ctx: &Ctx) -> Option<Saved> {
|
||||
|
||||
/// Le modèle du preset est-il hybride (reprise du cache aux points de
|
||||
/// contrôle) ?
|
||||
async fn model_hybrid(ctx: &Ctx, cfg: &Cfg) -> bool {
|
||||
pub(crate) async fn model_hybrid(ctx: &Ctx, cfg: &Cfg) -> bool {
|
||||
let m = cfg.get("MODEL").map(|s| s.trim()).unwrap_or("");
|
||||
if m.is_empty() {
|
||||
return false;
|
||||
|
||||
@@ -44,10 +44,10 @@ const DEPTH_MAX: i64 = 32768;
|
||||
const DEPTH_CPU: i64 = 16384;
|
||||
const DEPTH_MIN: i64 = 4096;
|
||||
const MARGIN: i64 = 512;
|
||||
const WARM_GEN: i64 = 32;
|
||||
pub(crate) const WARM_GEN: i64 = 32;
|
||||
const SEED: i64 = 1234;
|
||||
const REUSE_SLACK: i64 = 16;
|
||||
const SAMPLE_BYTES: usize = 160 << 10;
|
||||
pub(crate) const SAMPLE_BYTES: usize = 160 << 10;
|
||||
const FALLBACK_RATIO: f64 = 2.5;
|
||||
const THRASH_BYTES: i64 = 512 << 20;
|
||||
|
||||
@@ -62,7 +62,7 @@ pub const ERR_NO_TIMINGS: &str =
|
||||
pub const CORPUS: &str = include_str!("corpus.txt");
|
||||
|
||||
const ASK_PROSE: &str = "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics.";
|
||||
const ASK_FIRST: &str = "Résume en quelques phrases le rôle de ce fichier.";
|
||||
pub(crate) const ASK_FIRST: &str = "Résume en quelques phrases le rôle de ce fichier.";
|
||||
const ASK_NEXT: &str =
|
||||
"Écris en JavaScript une fonction courte inspirée de cette partie, sans explication.";
|
||||
|
||||
|
||||
@@ -373,7 +373,7 @@ pub(crate) async fn generating(ctx: &Ctx) -> bool {
|
||||
|
||||
/// `asrShutdownVite` : la dictée de la façade si elle la tient, sinon celle
|
||||
/// du Go.
|
||||
async fn dictate_stop(ctx: &Ctx) {
|
||||
pub(crate) async fn dictate_stop(ctx: &Ctx) {
|
||||
if let Some(d) = &ctx.dictate {
|
||||
d.shutdown_fast().await;
|
||||
return;
|
||||
|
||||
@@ -47,6 +47,7 @@ pub mod system;
|
||||
pub mod sysupdate;
|
||||
pub mod tasks;
|
||||
pub mod tools;
|
||||
pub mod tune;
|
||||
pub mod tz;
|
||||
pub mod ui;
|
||||
pub mod update;
|
||||
@@ -186,6 +186,13 @@ fn serve() -> ExitCode {
|
||||
// LOKI_BENCH=rust : le banc d'essai (routes, job, protocole) ; le
|
||||
// verrou de génération reste celui du Go, pris par le bail du moteur.
|
||||
let bench_rust = held_by_facade("LOKI_BENCH");
|
||||
// LOKI_TUNE=rust : l'optimiseur (routes, job, essais, application).
|
||||
// Sous Unix seulement : l'identité des processus que porte son verrou
|
||||
// (procIdentity du Go) n'est pas portée ailleurs.
|
||||
let tune_rust = held_by_facade("LOKI_TUNE") && cfg!(unix);
|
||||
if held_by_facade("LOKI_TUNE") && !tune_rust {
|
||||
eprintln!("[loki-rs] optimiseur tenu par le Go (Unix seulement)");
|
||||
}
|
||||
let engine_rust = held_by_facade("LOKI_ENGINE")
|
||||
&& match loki_rs::enginectl::usable(
|
||||
std::env::var("LOKI_CONTAINER").ok().as_deref(),
|
||||
@@ -222,12 +229,17 @@ fn serve() -> ExitCode {
|
||||
("LOKI_STRATA", strata_rust),
|
||||
("LOKI_TASKS", tasks_rust),
|
||||
("LOKI_BENCH", bench_rust),
|
||||
("LOKI_TUNE", tune_rust),
|
||||
];
|
||||
for (var, on) in held {
|
||||
if on {
|
||||
env.push((var.into(), "rust".into()));
|
||||
}
|
||||
}
|
||||
// Demandé mais pas tenu : le Go ne doit pas relayer ces routes.
|
||||
if !tune_rust && held_by_facade("LOKI_TUNE") {
|
||||
env.push(("LOKI_TUNE".into(), String::new()));
|
||||
}
|
||||
if held.iter().any(|(_, on)| *on) {
|
||||
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
|
||||
}
|
||||
@@ -357,6 +369,12 @@ fn serve() -> ExitCode {
|
||||
.then(|| std::sync::Arc::new(loki_rs::tasks::Tasks::new(engine_paths.home.clone())));
|
||||
let bench = bench_rust
|
||||
.then(|| std::sync::Arc::new(loki_rs::bench::job::Bench::new(engine_paths.clone())));
|
||||
let tune = tune_rust.then(|| {
|
||||
std::sync::Arc::new(loki_rs::tune::job::Tune::new(
|
||||
engine_paths.home.clone(),
|
||||
go_bin_path.clone(),
|
||||
))
|
||||
});
|
||||
let cfg = proxy::Config {
|
||||
backend,
|
||||
internal_token: token,
|
||||
@@ -380,6 +398,7 @@ fn serve() -> ExitCode {
|
||||
strata,
|
||||
tasks,
|
||||
bench,
|
||||
tune,
|
||||
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
|
||||
};
|
||||
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
|
||||
|
||||
@@ -109,6 +109,7 @@ pub struct Config {
|
||||
/// Banc d'essai (routes, job, protocole de mesure) tenu par la façade
|
||||
/// (`LOKI_BENCH=rust`) ; `None` : au Go.
|
||||
pub bench: Option<Arc<crate::bench::job::Bench>>,
|
||||
pub tune: Option<Arc<crate::tune::job::Tune>>,
|
||||
}
|
||||
|
||||
/// Arrête la tâche de fond tenue à sa sortie de portée.
|
||||
@@ -164,6 +165,7 @@ pub async fn serve(
|
||||
strata: cfg.strata,
|
||||
tasks: cfg.tasks,
|
||||
bench: cfg.bench,
|
||||
tune: cfg.tune,
|
||||
internal_token: cfg.internal_token.clone(),
|
||||
},
|
||||
});
|
||||
@@ -296,6 +298,11 @@ impl Proxy {
|
||||
{
|
||||
return crate::bench::job::serve(&self.ctx, b, req, from_go).await;
|
||||
}
|
||||
if let Some(t) = &self.ctx.tune
|
||||
&& crate::tune::job::handles(path)
|
||||
{
|
||||
return crate::tune::job::serve(&self.ctx, t, req, from_go).await;
|
||||
}
|
||||
if let Some(b) = &self.ctx.backends
|
||||
&& crate::backends::handles(path)
|
||||
{
|
||||
|
||||
@@ -219,7 +219,7 @@ fn ceil_quota(quota: &str, period: &str) -> i64 {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cpu_list(root: &Path, rel: &str) -> Option<Vec<i64>> {
|
||||
pub(crate) fn read_cpu_list(root: &Path, rel: &str) -> Option<Vec<i64>> {
|
||||
parse_cpu_list(&read(root, rel)?)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,615 @@
|
||||
//! Verrou de l'optimiseur — port de `backend_tune_lock.go`, au même format.
|
||||
//!
|
||||
//! Pendant une optimisation, la VRAM appartient au moteur d'ESSAI : relancer
|
||||
//! le vrai moteur à côté chargerait deux modèles sur des cartes qui n'en
|
||||
//! tiennent qu'un. Le verrou est un FICHIER de LOKI_HOME, créé de façon
|
||||
//! exclusive, que tout le monde lit : le Go (`tuneGuard`, tous ses refus), un
|
||||
//! « loki tune » en ligne de commande, et cette façade.
|
||||
//!
|
||||
//! Le fichier porte l'identité du processus qui le tient (PID, instant de
|
||||
//! démarrage, binaire) et celle de l'essai en cours. Un verrou dont le
|
||||
//! propriétaire est mort est périmé : l'essai orphelin est arrêté — seulement
|
||||
//! si PID, instant de démarrage ET binaire concordent — puis le fichier est
|
||||
//! retiré. L'identité est écrite comme le Go l'écrit (`procIdentity`) : la
|
||||
//! reprise du Go (`tuneRecoverWatch`, toutes les 30 s) doit reconnaître la
|
||||
//! façade vivante, sans quoi elle écarterait son verrou en pleine mesure.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// `tuneLockName`.
|
||||
pub const LOCK_NAME: &str = "tune.lock";
|
||||
|
||||
/// `tuneLockFreshness` : un verrou illisible plus jeune que ça est en cours
|
||||
/// d'écriture, pas abandonné.
|
||||
pub const FRESHNESS: Duration = Duration::from_secs(10);
|
||||
|
||||
/// `errTuneBusy`.
|
||||
pub const ERR_TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la";
|
||||
|
||||
/// `tuneProc` : un processus désigné sans ambiguïté.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Proc {
|
||||
pub pid: i64,
|
||||
/// `procIdentity` ; vide = illisible au moment de l'écrire.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub start: String,
|
||||
/// Binaires admis (un « loki serve » devient llama-server par exec).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exes: Vec<String>,
|
||||
}
|
||||
|
||||
/// `tuneOwner` : le contenu du verrou.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Owner {
|
||||
pub owner: Proc,
|
||||
/// « web », « cli » ou « apply ».
|
||||
#[serde(default)]
|
||||
pub via: String,
|
||||
/// « essais », « application », « relance », « rendu ».
|
||||
#[serde(default)]
|
||||
pub phase: String,
|
||||
#[serde(default)]
|
||||
pub since: i64,
|
||||
/// Distingue deux verrous pris par le même processus dans la même seconde.
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub nonce: i64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub trial: Option<Proc>,
|
||||
/// Le vrai moteur tournait avant l'optimisation.
|
||||
#[serde(default)]
|
||||
pub main_was_active: bool,
|
||||
/// Application en cours : la version d'avant du preset, sauvée avant
|
||||
/// toute écriture.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub backup: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub preset_id: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub preset_name: String,
|
||||
}
|
||||
|
||||
fn is_zero(v: &i64) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
/// `tuneHeldKey`.
|
||||
pub fn held_key(o: &Owner) -> String {
|
||||
format!("{}|{}|{}", o.via, o.since, o.nonce)
|
||||
}
|
||||
|
||||
/// `sameTuneLock` : la même prise de verrou, quelle que soit sa phase.
|
||||
pub fn same_lock(a: &Owner, b: &Owner) -> bool {
|
||||
a.owner.pid == b.owner.pid && a.owner.start == b.owner.start && held_key(a) == held_key(b)
|
||||
}
|
||||
|
||||
/// `sameExe` : deux binaires comparés par leur nom de fichier (sans .exe,
|
||||
/// sans casse).
|
||||
pub fn same_exe(a: &str, b: &str) -> bool {
|
||||
let norm = |s: &str| {
|
||||
let s = s.trim();
|
||||
let s = s.strip_suffix(" (deleted)").unwrap_or(s);
|
||||
let s = s.replace('\\', "/");
|
||||
let base = go_base(&s).to_lowercase();
|
||||
base.strip_suffix(".exe").unwrap_or(&base).to_string()
|
||||
};
|
||||
!a.is_empty() && !b.is_empty() && norm(a) == norm(b)
|
||||
}
|
||||
|
||||
/// `filepath.Base` (séparateur `/`).
|
||||
fn go_base(p: &str) -> String {
|
||||
let t = p.trim_end_matches('/');
|
||||
if t.is_empty() {
|
||||
return if p.is_empty() { ".".into() } else { "/".into() };
|
||||
}
|
||||
t.rsplit('/').next().unwrap_or(t).to_string()
|
||||
}
|
||||
|
||||
/// `readTuneOwner` : illisible, ou sans PID : pas de verrou.
|
||||
pub fn read_owner(path: &Path) -> Option<Owner> {
|
||||
let b = std::fs::read(path).ok()?;
|
||||
let o: Owner = serde_json::from_slice(&b).ok()?;
|
||||
(o.owner.pid > 0).then_some(o)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identité d'un processus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `procIdentity` (Linux) : instant de démarrage (champ 22 de
|
||||
/// `/proc/<pid>/stat`, préfixé de l'identifiant du démarrage de la machine)
|
||||
/// et binaire. Un zombie a fini de tourner : il ne compte plus.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn proc_identity(pid: i64) -> Option<(String, String)> {
|
||||
proc_identity_in(Path::new("/"), pid)
|
||||
}
|
||||
|
||||
/// [`proc_identity`] sous la racine `root` (les tests).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn proc_identity_in(root: &Path, pid: i64) -> Option<(String, String)> {
|
||||
if pid <= 0 {
|
||||
return None;
|
||||
}
|
||||
let s = std::fs::read_to_string(root.join(format!("proc/{pid}/stat"))).ok()?;
|
||||
let st = crate::engine::proctree::parse_proc_stat(&s)?;
|
||||
if st.state == "Z" {
|
||||
return None;
|
||||
}
|
||||
let exe = std::fs::read_link(root.join(format!("proc/{pid}/exe")))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
// Binaire remplacé par une mise à jour pendant que le processus tourne :
|
||||
// le lien porte « (deleted) », c'est pourtant bien le même processus.
|
||||
let exe = exe.strip_suffix(" (deleted)").unwrap_or(&exe).to_string();
|
||||
let mut start = st.start;
|
||||
if let Ok(b) = std::fs::read_to_string(root.join("proc/sys/kernel/random/boot_id")) {
|
||||
let id = b.trim();
|
||||
if !id.is_empty() {
|
||||
start = format!("{id}/{start}");
|
||||
}
|
||||
}
|
||||
Some((start, exe))
|
||||
}
|
||||
|
||||
/// `procIdentity` (autres Unix) : `ps`, langue et fuseau fixés — le
|
||||
/// processus web et un terminal en français doivent lire la même chaîne.
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
pub fn proc_identity(pid: i64) -> Option<(String, String)> {
|
||||
if pid <= 0 || !pid_alive(pid) {
|
||||
return None;
|
||||
}
|
||||
let p = pid.to_string();
|
||||
let out = std::process::Command::new("ps")
|
||||
.args(["-o", "lstart=", "-p", &p])
|
||||
.env("LC_ALL", "C")
|
||||
.env("LANG", "C")
|
||||
.env("TZ", "UTC")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let start = String::from_utf8_lossy(&out.stdout)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if start.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let exe = std::process::Command::new("ps")
|
||||
.args(["-o", "comm=", "-p", &p])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_default();
|
||||
Some((start, exe))
|
||||
}
|
||||
|
||||
/// Hors Unix, la façade ne tient pas l'optimiseur (`main.rs`).
|
||||
#[cfg(not(unix))]
|
||||
pub fn proc_identity(_pid: i64) -> Option<(String, String)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `processAlive` : `kill(pid, 0)`.
|
||||
#[cfg(unix)]
|
||||
pub fn pid_alive(pid: i64) -> bool {
|
||||
let Ok(p) = i32::try_from(pid) else {
|
||||
return false;
|
||||
};
|
||||
// SAFETY: kill(2) au signal 0 n'envoie rien ; un PID absent rend -1.
|
||||
p > 0 && unsafe { libc::kill(p, 0) == 0 }
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn pid_alive(_pid: i64) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
return false;
|
||||
};
|
||||
if !p.start.is_empty() && start != p.start {
|
||||
return false;
|
||||
}
|
||||
if exe.is_empty() || p.exes.is_empty() {
|
||||
return true;
|
||||
}
|
||||
p.exes.iter().any(|e| same_exe(e, &exe))
|
||||
}
|
||||
|
||||
/// `selfProc` : ce processus, tel que le verrou le note.
|
||||
pub fn self_proc() -> Proc {
|
||||
let pid = i64::from(std::process::id());
|
||||
let mut p = Proc {
|
||||
pid,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some((start, exe)) = proc_identity(pid) {
|
||||
p.start = start;
|
||||
if !exe.is_empty() {
|
||||
p.exes = vec![exe];
|
||||
}
|
||||
}
|
||||
if p.exes.is_empty()
|
||||
&& let Ok(me) = std::env::current_exe()
|
||||
{
|
||||
p.exes = vec![me.to_string_lossy().into_owned()];
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// `tuneKillTree` (Unix) : SIGTERM au groupe de l'essai, puis SIGKILL s'il
|
||||
/// traîne. `gone` dit si le processus a disparu (récolté par l'appelant, ou
|
||||
/// sondé pour un orphelin d'un ancien Loki).
|
||||
#[cfg(unix)]
|
||||
pub fn kill_tree(pid: i64, gone: &dyn Fn() -> bool) {
|
||||
let Ok(p) = i32::try_from(pid) else {
|
||||
return;
|
||||
};
|
||||
if p <= 0 {
|
||||
return;
|
||||
}
|
||||
let wait = |d: Duration| {
|
||||
let deadline = std::time::Instant::now() + d;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if gone() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
};
|
||||
// SAFETY: kill(2) n'a pas d'effet mémoire.
|
||||
unsafe {
|
||||
if libc::kill(-p, libc::SIGTERM) != 0 {
|
||||
libc::kill(p, libc::SIGTERM);
|
||||
}
|
||||
}
|
||||
if wait(Duration::from_secs(10)) {
|
||||
return;
|
||||
}
|
||||
// SAFETY: idem.
|
||||
unsafe {
|
||||
libc::kill(-p, libc::SIGKILL);
|
||||
}
|
||||
wait(Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn kill_tree(_pid: i64, _gone: &dyn Fn() -> bool) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Le verrou
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Qui est vivant : [`proc_alive`] hors tests.
|
||||
pub type AliveFn = Box<dyn Fn(&Proc) -> bool + Send + Sync>;
|
||||
|
||||
/// Le fichier de verrou de CE processus : les prises en cours (`tuneHeld`)
|
||||
/// et le verrou des opérations sur le fichier (`tuneFileMu`).
|
||||
pub struct LockFile {
|
||||
pub path: PathBuf,
|
||||
held: Mutex<HashSet<String>>,
|
||||
file_mu: Mutex<()>,
|
||||
alive: AliveFn,
|
||||
pid: i64,
|
||||
me: Proc,
|
||||
}
|
||||
|
||||
/// Une prise de verrou à défaire après le ménage d'un verrou périmé : l'essai
|
||||
/// orphelin à arrêter et l'application à défaire sont à l'appelant.
|
||||
pub type Claimed = Owner;
|
||||
|
||||
impl LockFile {
|
||||
pub fn new(home: &Path) -> Self {
|
||||
Self::with(home, Box::new(proc_alive), self_proc())
|
||||
}
|
||||
|
||||
/// `alive` et `me` remplaçables par les tests.
|
||||
pub fn with(home: &Path, alive: AliveFn, me: Proc) -> Self {
|
||||
LockFile {
|
||||
path: home.join(LOCK_NAME),
|
||||
held: Mutex::new(HashSet::new()),
|
||||
file_mu: Mutex::new(()),
|
||||
alive,
|
||||
pid: me.pid,
|
||||
me,
|
||||
}
|
||||
}
|
||||
|
||||
fn held(&self) -> std::sync::MutexGuard<'_, HashSet<String>> {
|
||||
self.held.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn file(&self) -> std::sync::MutexGuard<'_, ()> {
|
||||
self.file_mu.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// `tuneOwnerAlive` : un verrou qui porte NOTRE PID sans être l'un des
|
||||
/// nôtres vient d'un processus mort dont le PID nous est échu.
|
||||
pub fn owner_alive(&self, o: &Owner) -> bool {
|
||||
if o.owner.pid == self.pid && !self.held().contains(&held_key(o)) {
|
||||
return false;
|
||||
}
|
||||
(self.alive)(&o.owner)
|
||||
}
|
||||
|
||||
pub fn proc_alive(&self, p: &Proc) -> bool {
|
||||
(self.alive)(p)
|
||||
}
|
||||
|
||||
/// `tuneActive` : une optimisation tient-elle le moteur ? Un verrou
|
||||
/// périmé ne compte pas.
|
||||
pub fn active(&self) -> Option<Owner> {
|
||||
let o = read_owner(&self.path)?;
|
||||
self.owner_alive(&o).then_some(o)
|
||||
}
|
||||
|
||||
/// `tuneLockAcquire`, premier temps : la prise, ou qui la tient. `Err(None)`
|
||||
/// : un verrou périmé à écarter ([`Self::reap_claim`]) avant de réessayer.
|
||||
fn try_acquire(&self, owner: &Owner) -> Result<(), Option<String>> {
|
||||
let _f = self.file();
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o644);
|
||||
}
|
||||
match opts.open(&self.path) {
|
||||
Ok(mut f) => {
|
||||
use std::io::Write as _;
|
||||
let b = serde_json::to_vec(owner).unwrap_or_default();
|
||||
let w = f.write_all(&b).and_then(|_| f.sync_all());
|
||||
drop(f);
|
||||
if let Err(e) = w {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
return Err(Some(format!("verrou de l'optimiseur : {e}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(None),
|
||||
Err(e) => Err(Some(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `tuneLockAcquire` : prend le verrou, ou dit qui le tient. `reap` écarte
|
||||
/// un verrou périmé (essai orphelin et application interrompue compris).
|
||||
pub async fn acquire<F, Fut>(
|
||||
&self,
|
||||
via: &str,
|
||||
main_active: bool,
|
||||
reap: F,
|
||||
) -> Result<Held<'_>, String>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: std::future::Future<Output = bool>,
|
||||
{
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default();
|
||||
let owner = Owner {
|
||||
owner: self.me.clone(),
|
||||
via: via.into(),
|
||||
phase: "essais".into(),
|
||||
since: now.as_secs() as i64,
|
||||
nonce: now.as_nanos() as i64,
|
||||
main_was_active: main_active,
|
||||
..Default::default()
|
||||
};
|
||||
// Inscrit comme nôtre AVANT que le fichier n'existe : la reprise tient
|
||||
// pour mort un verrou qui porte notre PID sans être inscrit.
|
||||
let key = held_key(&owner);
|
||||
self.held().insert(key.clone());
|
||||
for _ in 0..3 {
|
||||
match self.try_acquire(&owner) {
|
||||
Ok(()) => {
|
||||
return Ok(Held {
|
||||
file: self,
|
||||
owner: Mutex::new(owner),
|
||||
held: Mutex::new(true),
|
||||
});
|
||||
}
|
||||
Err(Some(e)) => {
|
||||
self.held().remove(&key);
|
||||
return Err(e);
|
||||
}
|
||||
Err(None) => {}
|
||||
}
|
||||
if let Some(o) = self.active() {
|
||||
self.held().remove(&key);
|
||||
let c = crate::tz::Location::local().civil(o.since);
|
||||
return Err(format!(
|
||||
"une optimisation tourne déjà ({}, PID {}, depuis {:02}:{:02})",
|
||||
o.via, o.owner.pid, c.hour, c.minute
|
||||
));
|
||||
}
|
||||
reap().await;
|
||||
}
|
||||
self.held().remove(&key);
|
||||
Err("verrou de l'optimiseur indisponible : réessaie".into())
|
||||
}
|
||||
|
||||
/// `tuneReapClaim` : la partie du ménage qui touche au fichier — lecture,
|
||||
/// déplacement, relecture, remise en place. Rend le verrou périmé
|
||||
/// effectivement retiré (`None` : rien à écarter).
|
||||
pub fn reap_claim(&self) -> Option<Claimed> {
|
||||
let _f = self.file();
|
||||
let Some(o) = read_owner(&self.path) else {
|
||||
// Illisible (écriture interrompue) : périmé lui aussi — mais pas
|
||||
// tout juste créé (un verrou neuf est vide quelques instants).
|
||||
if let Ok(md) = std::fs::metadata(&self.path)
|
||||
&& md
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|m| m.elapsed().ok())
|
||||
.is_some_and(|age| age > FRESHNESS)
|
||||
{
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
if self.owner_alive(&o) {
|
||||
return None;
|
||||
}
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let stale = PathBuf::from(format!(
|
||||
"{}.stale-{}-{nanos}",
|
||||
self.path.display(),
|
||||
self.pid
|
||||
));
|
||||
std::fs::rename(&self.path, &stale).ok()?;
|
||||
let back = |stale: &Path| {
|
||||
// Un autre verrou, vivant, déplacé par erreur : remis en place (un
|
||||
// lien : jamais par-dessus un verrou posé entre-temps).
|
||||
if let Err(e) = std::fs::hard_link(stale, &self.path) {
|
||||
eprintln!(
|
||||
"[loki tune] verrou vivant déplacé par erreur, remise en place impossible : {e}"
|
||||
);
|
||||
}
|
||||
};
|
||||
let got = read_owner(&stale);
|
||||
let claimed = match got {
|
||||
Some(g) if same_lock(&g, &o) && !self.owner_alive(&g) => Some(o),
|
||||
_ => {
|
||||
back(&stale);
|
||||
None
|
||||
}
|
||||
};
|
||||
let _ = std::fs::remove_file(&stale);
|
||||
claimed
|
||||
}
|
||||
}
|
||||
|
||||
/// Le verrou tenu par CE processus (`tuneLock`).
|
||||
pub struct Held<'a> {
|
||||
file: &'a LockFile,
|
||||
owner: Mutex<Owner>,
|
||||
held: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl Held<'_> {
|
||||
fn owner(&self) -> std::sync::MutexGuard<'_, Owner> {
|
||||
self.owner.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Owner {
|
||||
self.owner().clone()
|
||||
}
|
||||
|
||||
/// `writeLocked` : réécrit d'un bloc (fichier voisin puis renommage) — un
|
||||
/// lecteur ne voit jamais un JSON à moitié écrit.
|
||||
fn write_locked(&self, o: &Owner) -> std::io::Result<()> {
|
||||
let b = serde_json::to_vec(o).map_err(std::io::Error::other)?;
|
||||
let tmp = PathBuf::from(format!(
|
||||
"{}.tmp-{}",
|
||||
self.file.path.display(),
|
||||
self.file.pid
|
||||
));
|
||||
std::fs::write(&tmp, b)?;
|
||||
let mut i = 0;
|
||||
loop {
|
||||
match std::fs::rename(&tmp, &self.file.path) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if i == 4 => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
Err(_) => {
|
||||
i += 1;
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(&self, f: impl FnOnce(&mut Owner)) {
|
||||
let mut o = self.owner();
|
||||
f(&mut o);
|
||||
let _f = self.file.file();
|
||||
let _ = self.write_locked(&o);
|
||||
}
|
||||
|
||||
/// `setTrial` : l'essai en cours (`None` : aucun), noté AVANT qu'il ne
|
||||
/// charge quoi que ce soit — c'est ce qui permet de l'arrêter après un
|
||||
/// redémarrage.
|
||||
pub fn set_trial(&self, p: Option<Proc>) {
|
||||
self.update(|o| o.trial = p);
|
||||
}
|
||||
|
||||
pub fn set_phase(&self, phase: &str) {
|
||||
self.update(|o| o.phase = phase.into());
|
||||
}
|
||||
|
||||
/// `setBackup` : la sauvegarde du preset avant son écriture.
|
||||
pub fn set_backup(&self, backup: &str, id: &str, name: &str) {
|
||||
self.update(|o| {
|
||||
o.backup = backup.into();
|
||||
o.preset_id = id.into();
|
||||
o.preset_name = name.into();
|
||||
});
|
||||
}
|
||||
|
||||
/// `release` : rend le verrou — seulement s'il est encore le nôtre. Réécrit
|
||||
/// d'abord NEUTRE (rien à défaire, rien à relancer, aucun essai), puis
|
||||
/// retiré ; l'inscription ne part qu'après.
|
||||
pub fn release(&self) {
|
||||
let mut o = self.owner();
|
||||
let _f = self.file.file();
|
||||
let mut held = self.held.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *held {
|
||||
let mut ours = false;
|
||||
for _ in 0..5 {
|
||||
if let Some(cur) = read_owner(&self.file.path) {
|
||||
ours = same_lock(&cur, &o);
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
if ours {
|
||||
o.phase = "rendu".into();
|
||||
o.trial = None;
|
||||
o.main_was_active = false;
|
||||
o.backup.clear();
|
||||
o.preset_id.clear();
|
||||
o.preset_name.clear();
|
||||
let _ = self.write_locked(&o);
|
||||
for _ in 0..5 {
|
||||
match std::fs::remove_file(&self.file.path) {
|
||||
Ok(()) => break,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => break,
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
*held = false;
|
||||
self.file.held().remove(&held_key(&o));
|
||||
return;
|
||||
}
|
||||
if read_owner(&self.file.path).is_some_and(|cur| same_lock(&cur, &o)) {
|
||||
let _ = std::fs::remove_file(&self.file.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Held<'_> {
|
||||
fn drop(&mut self) {
|
||||
if *self.held.lock().unwrap_or_else(|e| e.into_inner()) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Optimiseur sans perte tenu par la façade (lot 28 de la migration,
|
||||
//! `LOKI_TUNE=rust` ; expérimental, le Go reste le défaut) — port de
|
||||
//! `backend_tune*.go`.
|
||||
//!
|
||||
//! - [`core`] : la partie pure (essais, garde-fous, score, réécriture du
|
||||
//! preset) ;
|
||||
//! - [`lock`] : le verrou de LOKI_HOME, au format du Go ;
|
||||
//! - [`job`] : les routes, le job, les essais et l'application.
|
||||
|
||||
pub mod core;
|
||||
pub mod job;
|
||||
pub mod lock;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Tests de l'optimiseur : ceux du verrou (`backend_tune_test.go`), portés,
|
||||
//! et les aides du job.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use super::lock::*;
|
||||
|
||||
fn me() -> Proc {
|
||||
Proc {
|
||||
pid: i64::from(std::process::id()),
|
||||
start: "moi".into(),
|
||||
exes: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// `TestTuneLock` : le verrou se prend, bloque, note l'essai, se rend ; un
|
||||
/// propriétaire mort le rend périmé et le ménage le retire.
|
||||
#[tokio::test]
|
||||
async fn verrou() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let a = alive.clone();
|
||||
let pid = me().pid;
|
||||
let lf = LockFile::with(
|
||||
tmp.path(),
|
||||
Box::new(move |p: &Proc| a.load(Ordering::SeqCst) && p.pid == pid),
|
||||
me(),
|
||||
);
|
||||
let no_reap = || async { false };
|
||||
assert!(lf.active().is_none(), "garde levée sans verrou");
|
||||
let l = lf.acquire("cli", true, no_reap).await.unwrap();
|
||||
assert!(lf.active().is_some(), "garde muette pendant l'optimisation");
|
||||
let e = lf.acquire("web", false, no_reap).await.err().unwrap();
|
||||
assert!(
|
||||
e.starts_with(&format!(
|
||||
"une optimisation tourne déjà (cli, PID {pid}, depuis "
|
||||
)),
|
||||
"{e}"
|
||||
);
|
||||
l.set_trial(Some(Proc {
|
||||
pid: 999999,
|
||||
start: "x".into(),
|
||||
exes: vec![],
|
||||
}));
|
||||
let o = read_owner(&lf.path).unwrap();
|
||||
assert_eq!(o.trial.as_ref().map(|t| t.pid), Some(999999));
|
||||
assert!(o.main_was_active && o.phase == "essais" && o.via == "cli");
|
||||
l.set_phase("relance");
|
||||
l.set_backup("/b", "Dense", "Dense");
|
||||
let o = read_owner(&lf.path).unwrap();
|
||||
assert_eq!(
|
||||
(o.phase.as_str(), o.backup.as_str(), o.preset_id.as_str()),
|
||||
("relance", "/b", "Dense")
|
||||
);
|
||||
l.release();
|
||||
assert!(!lf.path.exists(), "verrou non rendu");
|
||||
|
||||
// Propriétaire mort : périmé, il ne bloque rien et cède la place.
|
||||
let l = lf.acquire("cli", true, no_reap).await.unwrap();
|
||||
l.set_trial(Some(Proc {
|
||||
pid: 999999,
|
||||
start: "x".into(),
|
||||
exes: vec![],
|
||||
}));
|
||||
alive.store(false, Ordering::SeqCst);
|
||||
assert!(lf.active().is_none(), "verrou périmé encore bloquant");
|
||||
let claimed = lf.reap_claim().expect("verrou périmé non écarté");
|
||||
assert!(claimed.main_was_active, "MainWasActive perdu au nettoyage");
|
||||
assert!(!lf.path.exists(), "verrou périmé non retiré");
|
||||
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();
|
||||
std::fs::write(&lf.path, serde_json::to_vec(&other).unwrap()).unwrap();
|
||||
assert!(lf.path.exists());
|
||||
}
|
||||
|
||||
/// Un verrou périmé dans la place, et `acquire` qui le fait écarter.
|
||||
#[tokio::test]
|
||||
async fn verrou_perime_ecarte_a_la_prise() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let lf = LockFile::with(tmp.path(), Box::new(|_: &Proc| false), me());
|
||||
let dead = Owner {
|
||||
owner: Proc {
|
||||
pid: 999999,
|
||||
start: "x".into(),
|
||||
exes: vec![],
|
||||
},
|
||||
via: "cli".into(),
|
||||
phase: "essais".into(),
|
||||
main_was_active: true,
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(&lf.path, serde_json::to_vec(&dead).unwrap()).unwrap();
|
||||
let reaped = std::sync::Mutex::new(0);
|
||||
let l = lf
|
||||
.acquire("web", false, || async {
|
||||
*reaped.lock().unwrap() += 1;
|
||||
lf.reap_claim().is_some()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*reaped.lock().unwrap(), 1);
|
||||
assert_eq!(read_owner(&lf.path).unwrap().via, "web");
|
||||
drop(l);
|
||||
assert!(!lf.path.exists(), "verrou lâché sans release : rendu");
|
||||
}
|
||||
|
||||
/// `TestTuneLockStaleSelfAndFresh` : un verrou à notre PID qui n'est pas
|
||||
/// l'un des nôtres est périmé ; illisible et neuf il reste, vieux il part.
|
||||
#[tokio::test]
|
||||
async fn verrou_perime_de_soi_et_frais() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let lf = LockFile::with(tmp.path(), Box::new(|_: &Proc| true), me());
|
||||
let stale = Owner {
|
||||
owner: me(),
|
||||
via: "web".into(),
|
||||
phase: "essais".into(),
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(&lf.path, serde_json::to_vec(&stale).unwrap()).unwrap();
|
||||
assert!(
|
||||
lf.active().is_none(),
|
||||
"verrou d'un ancien processus au même PID encore bloquant"
|
||||
);
|
||||
let l = lf
|
||||
.acquire("cli", false, || async { lf.reap_claim().is_some() })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(lf.active().is_some(), "notre propre verrou ne bloque plus");
|
||||
let mine = l.snapshot();
|
||||
l.release();
|
||||
// Rendu : plus inscrit comme tenu, le même contenu est périmé.
|
||||
std::fs::write(&lf.path, serde_json::to_vec(&mine).unwrap()).unwrap();
|
||||
assert!(
|
||||
lf.active().is_none(),
|
||||
"verrou rendu encore inscrit comme tenu"
|
||||
);
|
||||
std::fs::remove_file(&lf.path).unwrap();
|
||||
|
||||
// Illisible et neuf : laissé en place.
|
||||
std::fs::write(&lf.path, b"").unwrap();
|
||||
assert!(lf.reap_claim().is_none());
|
||||
assert!(lf.path.exists(), "verrou en cours d'écriture retiré");
|
||||
// Illisible et vieux : écarté.
|
||||
let f = std::fs::File::options().write(true).open(&lf.path).unwrap();
|
||||
f.set_modified(SystemTime::now() - 2 * FRESHNESS).unwrap();
|
||||
drop(f);
|
||||
assert!(lf.reap_claim().is_none());
|
||||
assert!(!lf.path.exists(), "verrou illisible abandonné non retiré");
|
||||
}
|
||||
|
||||
/// Le ménage relit ce qu'il a déplacé : un verrou vivant pris entre la
|
||||
/// lecture et le déplacement est remis en place.
|
||||
#[tokio::test]
|
||||
async fn menage_jamais_sur_un_verrou_vivant() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let alive = Arc::new(AtomicBool::new(false));
|
||||
let a = alive.clone();
|
||||
let lf = LockFile::with(
|
||||
tmp.path(),
|
||||
Box::new(move |_: &Proc| a.load(Ordering::SeqCst)),
|
||||
me(),
|
||||
);
|
||||
let o = Owner {
|
||||
owner: Proc {
|
||||
pid: 999999,
|
||||
start: "x".into(),
|
||||
exes: vec![],
|
||||
},
|
||||
via: "cli".into(),
|
||||
..Default::default()
|
||||
};
|
||||
std::fs::write(&lf.path, serde_json::to_vec(&o).unwrap()).unwrap();
|
||||
alive.store(true, Ordering::SeqCst);
|
||||
assert!(lf.reap_claim().is_none(), "verrou vivant écarté");
|
||||
assert_eq!(read_owner(&lf.path).unwrap(), o);
|
||||
// Prises et rendus répétés pendant que le ménage tourne : jamais écarté.
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let lf = Arc::new(LockFile::with(
|
||||
tmp.path(),
|
||||
Box::new(|p: &Proc| p.pid == i64::from(std::process::id())),
|
||||
me(),
|
||||
));
|
||||
std::fs::remove_file(&lf.path).unwrap();
|
||||
let (s2, lf2) = (stop.clone(), lf.clone());
|
||||
let reaper = std::thread::spawn(move || {
|
||||
let mut n = 0;
|
||||
while !s2.load(Ordering::SeqCst) {
|
||||
if lf2.reap_claim().is_some() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
});
|
||||
for i in 0..60 {
|
||||
let l = lf
|
||||
.acquire("apply", true, || async { false })
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("tour {i} : {e}"));
|
||||
l.set_phase("application");
|
||||
l.release();
|
||||
}
|
||||
stop.store(true, Ordering::SeqCst);
|
||||
assert_eq!(
|
||||
reaper.join().unwrap(),
|
||||
0,
|
||||
"verrou vivant de ce processus écarté"
|
||||
);
|
||||
}
|
||||
|
||||
/// `TestTuneSameExeDeleted`.
|
||||
#[test]
|
||||
fn binaire_remplace() {
|
||||
assert!(same_exe(
|
||||
"/usr/local/bin/loki",
|
||||
"/usr/local/bin/loki (deleted)"
|
||||
));
|
||||
assert!(!same_exe("/usr/local/bin/loki", "/usr/bin/llama-server"));
|
||||
assert!(!same_exe("", ""));
|
||||
}
|
||||
|
||||
/// L'identité d'un processus comme le Go l'écrit : instant de démarrage
|
||||
/// préfixé de l'identifiant du démarrage, binaire sans « (deleted) ».
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn identite_d_un_processus() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let p = root.join("proc/77");
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
std::fs::create_dir_all(root.join("proc/sys/kernel/random")).unwrap();
|
||||
let stat = |state: &str| {
|
||||
format!("77 (llama (x) server) {state} 1 77 77 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 4242 0 0")
|
||||
};
|
||||
std::fs::write(p.join("stat"), stat("S")).unwrap();
|
||||
std::os::unix::fs::symlink("/opt/llama-server (deleted)", p.join("exe")).unwrap();
|
||||
assert_eq!(
|
||||
proc_identity_in(root, 77),
|
||||
Some(("4242".into(), "/opt/llama-server".into()))
|
||||
);
|
||||
std::fs::write(root.join("proc/sys/kernel/random/boot_id"), "b0c1\n").unwrap();
|
||||
assert_eq!(proc_identity_in(root, 77).unwrap().0, "b0c1/4242");
|
||||
std::fs::write(p.join("stat"), stat("Z")).unwrap();
|
||||
assert_eq!(proc_identity_in(root, 77), None, "zombie");
|
||||
assert_eq!(proc_identity_in(root, 0), None);
|
||||
assert_eq!(proc_identity_in(root, 78), None);
|
||||
// Ce processus : vivant, reconnu, et pas un autre au même PID.
|
||||
let me = self_proc();
|
||||
assert!(proc_alive(&me));
|
||||
assert!(!proc_alive(&Proc {
|
||||
start: format!("{}x", me.start),
|
||||
..me.clone()
|
||||
}));
|
||||
assert!(!proc_alive(&Proc {
|
||||
exes: vec!["/usr/bin/autre".into()],
|
||||
..me.clone()
|
||||
}));
|
||||
assert!(proc_alive(&Proc {
|
||||
exes: vec![],
|
||||
start: String::new(),
|
||||
..me
|
||||
}));
|
||||
}
|
||||
|
||||
/// `TestTuneReapOrphan` (la partie processus) : le groupe de l'essai est
|
||||
/// arrêté, SIGTERM d'abord.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn arret_du_groupe() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("60")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = i64::from(child.id());
|
||||
assert!(pid_alive(pid));
|
||||
let t0 = std::time::Instant::now();
|
||||
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(pid, &|| reaped.load(Ordering::SeqCst));
|
||||
waiter.join().unwrap();
|
||||
assert!(t0.elapsed() < Duration::from_secs(5), "SIGTERM ignoré ?");
|
||||
assert!(!pid_alive(pid));
|
||||
}
|
||||
@@ -117,6 +117,14 @@ pub struct FakeGo {
|
||||
pub bench_perfs: Mutex<Vec<serde_json::Value>>,
|
||||
/// Délai avant de répondre à une prise de bail.
|
||||
pub lease_delay: Mutex<Option<std::time::Duration>>,
|
||||
/// /internal/v1/tune/context : refus du processus web et télémétrie
|
||||
/// (« main_active » : `service_active`).
|
||||
pub tune_ctx_busy: Mutex<String>,
|
||||
pub tune_perf: Mutex<serde_json::Value>,
|
||||
/// /internal/v1/service/action reçus avec « os » ; une erreur à rendre
|
||||
/// pour une action donnée.
|
||||
pub service_os: Mutex<Vec<String>>,
|
||||
pub service_os_fail: Mutex<Option<(String, String)>>,
|
||||
}
|
||||
|
||||
impl FakeGo {
|
||||
@@ -470,6 +478,19 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
let body = req.into_body().collect().await.unwrap().to_bytes();
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
|
||||
let if_active = v["if_active"].as_bool().unwrap_or(false);
|
||||
if v["os"].as_bool() == Some(true) {
|
||||
let action = v["action"].as_str().unwrap_or("").to_string();
|
||||
st.service_os.lock().unwrap().push(action.clone());
|
||||
let fail = st.service_os_fail.lock().unwrap().clone();
|
||||
if let Some((a, e)) = fail.filter(|(a, _)| *a == action) {
|
||||
let _ = a;
|
||||
return resp(
|
||||
200,
|
||||
serde_json::json!({"ran": true, "error": e}).to_string(),
|
||||
);
|
||||
}
|
||||
return resp(200, r#"{"ran":true,"error":""}"#);
|
||||
}
|
||||
st.service_actions
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -479,6 +500,14 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
|
||||
}
|
||||
resp(200, r#"{"ran":true,"error":""}"#)
|
||||
}
|
||||
"/internal/v1/tune/context" => {
|
||||
let v = serde_json::json!({
|
||||
"busy": *st.tune_ctx_busy.lock().unwrap(),
|
||||
"main_active": *st.service_active.lock().unwrap(),
|
||||
"perf": *st.tune_perf.lock().unwrap(),
|
||||
});
|
||||
resp(200, v.to_string())
|
||||
}
|
||||
"/internal/v1/dictate/stop" => {
|
||||
*st.dictate_stops.lock().unwrap() += 1;
|
||||
resp(204, "")
|
||||
@@ -756,6 +785,7 @@ pub struct Opts {
|
||||
pub strata: Option<Arc<loki_rs::strata::api::Strata>>,
|
||||
pub tasks: Option<Arc<loki_rs::tasks::Tasks>>,
|
||||
pub bench: Option<Arc<loki_rs::bench::job::Bench>>,
|
||||
pub tune: Option<Arc<loki_rs::tune::job::Tune>>,
|
||||
/// Base de départ, posée avant que la façade démarre : (bucket, clé,
|
||||
/// valeur).
|
||||
pub seed: Vec<(String, String, String)>,
|
||||
@@ -868,6 +898,7 @@ pub async fn start_with(
|
||||
strata: opts.strata,
|
||||
tasks: opts.tasks,
|
||||
bench: opts.bench,
|
||||
tune: opts.tune,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
@@ -111,6 +111,7 @@ async fn banc(cle: Option<&str>) -> Banc {
|
||||
strata: None,
|
||||
tasks: None,
|
||||
bench: None,
|
||||
tune: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -117,6 +117,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
|
||||
strata: None,
|
||||
tasks: None,
|
||||
bench: None,
|
||||
tune: None,
|
||||
};
|
||||
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
|
||||
let _ = rx.await;
|
||||
|
||||
@@ -0,0 +1,821 @@
|
||||
//! Lot 28 : l'optimiseur tenu par la façade (`LOKI_TUNE=rust`).
|
||||
//!
|
||||
//! Les essais passent par de vrais processus : ce binaire de test joue
|
||||
//! « loki serve » (la seule fonction dont le nom contient « serve »,
|
||||
//! sélectionnée par l'argument que la façade passe). À blanc, il écrit la
|
||||
//! ligne de commande de l'essai ; pour de vrai, il sert un faux llama-server
|
||||
//! dont le prefill est d'autant plus rapide que le micro-lot est petit.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use common::fakego;
|
||||
use loki_rs::tune::job::Tune;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const FAUX: &str = "LOKI_TUNE_FAUX";
|
||||
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à.
|
||||
#[derive(Default)]
|
||||
struct Knobs {
|
||||
n_ctx: Mutex<i64>,
|
||||
busy: Mutex<bool>,
|
||||
}
|
||||
|
||||
/// Un faux llama-server (fil à part, jusqu'à la fin du processus).
|
||||
fn fake_llama(listener: TcpListener, ubatch: i64, knobs: Arc<Knobs>) {
|
||||
let last = Arc::new(Mutex::new(0i64));
|
||||
std::thread::spawn(move || {
|
||||
for st in listener.incoming().flatten() {
|
||||
let (last, knobs) = (last.clone(), knobs.clone());
|
||||
std::thread::spawn(move || {
|
||||
let _ = answer(st, ubatch, &knobs, &last);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn answer(
|
||||
st: std::net::TcpStream,
|
||||
ubatch: i64,
|
||||
knobs: &Knobs,
|
||||
last: &Mutex<i64>,
|
||||
) -> std::io::Result<()> {
|
||||
let n_ctx = *knobs.n_ctx.lock().unwrap();
|
||||
let mut r = BufReader::new(st.try_clone()?);
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line)?;
|
||||
let path = line.split(' ').nth(1).unwrap_or("").to_string();
|
||||
let mut len = 0usize;
|
||||
loop {
|
||||
let mut h = String::new();
|
||||
r.read_line(&mut h)?;
|
||||
if h.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Some((k, v)) = h.split_once(':')
|
||||
&& k.eq_ignore_ascii_case("content-length")
|
||||
{
|
||||
len = v.trim().parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let mut body = vec![0u8; len];
|
||||
r.read_exact(&mut body)?;
|
||||
let req: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
|
||||
let out = match path.as_str() {
|
||||
"/health" => json!({ "status": "ok" }),
|
||||
"/props" => json!({
|
||||
"n_ctx": n_ctx, "total_slots": 1, "build_info": "b7000-faux",
|
||||
"default_generation_settings": { "n_ctx": n_ctx },
|
||||
}),
|
||||
"/slots" => json!([{ "id": 0, "is_processing": *knobs.busy.lock().unwrap() }]),
|
||||
"/tokenize" => {
|
||||
let n = req["content"].as_str().unwrap_or("").len() / 4;
|
||||
json!({ "tokens": vec![0; n.max(1)] })
|
||||
}
|
||||
"/v1/chat/completions" => {
|
||||
let chars: usize = req["messages"]
|
||||
.as_array()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.map(|x| x["content"].as_str().unwrap_or("").len())
|
||||
.sum()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let total = (chars / 4).max(1) as i64;
|
||||
let mut l = last.lock().unwrap();
|
||||
let cached = if req["cache_prompt"] == json!(true) && *l <= total {
|
||||
*l
|
||||
} else {
|
||||
0
|
||||
};
|
||||
*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 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,
|
||||
"cache_n": cached,
|
||||
},
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
let mut w = st;
|
||||
return w.write_all(
|
||||
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
}
|
||||
};
|
||||
let b = out.to_string();
|
||||
let mut w = st;
|
||||
write!(
|
||||
w,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}",
|
||||
b.len()
|
||||
)
|
||||
}
|
||||
|
||||
/// « loki serve » pour l'optimiseur : ce binaire relancé par la façade avec
|
||||
/// `LOKI_TUNE_FAUX`. Ailleurs : rien.
|
||||
#[test]
|
||||
fn serve() {
|
||||
if std::env::var(FAUX).is_err() {
|
||||
return;
|
||||
}
|
||||
let dir = PathBuf::from(std::env::var("LOKI_TUNE_TRIAL").unwrap());
|
||||
let cfg =
|
||||
loki_rs::presets::parse_env(&std::fs::read_to_string(dir.join("config.env")).unwrap());
|
||||
let get = |k: &str| cfg.get(k).cloned().unwrap_or_default();
|
||||
let ub = get("UBATCH");
|
||||
let die = |msg: &str| -> ! {
|
||||
let _ = std::io::stderr().write_all(format!("{msg}\n").as_bytes());
|
||||
std::process::exit(1);
|
||||
};
|
||||
if std::env::var("LOKI_TUNE_DRYRUN").as_deref() == Ok("1") {
|
||||
if get("REFUS") == ub {
|
||||
die(&format!("\x1b[31m[err]\x1b[0m UBATCH={ub} refusé"));
|
||||
}
|
||||
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() {
|
||||
args.extend([f.to_string(), get(k)]);
|
||||
}
|
||||
}
|
||||
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 },
|
||||
});
|
||||
std::fs::write(dir.join("launch.json"), launch.to_string()).unwrap();
|
||||
return;
|
||||
}
|
||||
if get("ECHEC") == ub {
|
||||
die("llama_model_load: error loading model: out of memory");
|
||||
}
|
||||
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();
|
||||
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));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Le banc
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Tn {
|
||||
facade: std::net::SocketAddr,
|
||||
go: Arc<fakego::FakeGo>,
|
||||
_stop: tokio::sync::oneshot::Sender<()>,
|
||||
home: PathBuf,
|
||||
port: u16,
|
||||
main: Arc<Knobs>,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
const PRESET: &str = "# NAME=Dense\nCTX=32768\nUBATCH=2048\nREASONING=off\nCACHE_ISOLATE=off\n";
|
||||
|
||||
async fn start() -> 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());
|
||||
t.trial_env = vec![(FAUX.into(), "1".into())];
|
||||
t.poll = Duration::from_millis(20);
|
||||
t.probe_poll = Duration::from_millis(20);
|
||||
t.settle = Duration::ZERO;
|
||||
t.load_timeout = Duration::from_secs(20);
|
||||
t.lease_ttl = Duration::from_millis(300);
|
||||
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;
|
||||
// Le vrai moteur, en service.
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
let main: Arc<Knobs> = Arc::default();
|
||||
*main.n_ctx.lock().unwrap() = 32768;
|
||||
fake_llama(l, 2048, main.clone());
|
||||
let tn = Tn {
|
||||
facade,
|
||||
go,
|
||||
_stop: stop,
|
||||
home,
|
||||
port,
|
||||
main,
|
||||
_tmp: tmp,
|
||||
};
|
||||
tn.preset(PRESET);
|
||||
*tn.go.service_active.lock().unwrap() = true;
|
||||
*tn.go.apply_presets.lock().unwrap() = true;
|
||||
tn
|
||||
}
|
||||
|
||||
impl Tn {
|
||||
/// Le preset « dense », et la configuration qui lui correspond (elle
|
||||
/// seule).
|
||||
fn preset(&self, content: &str) {
|
||||
let dir = self.home.join("presets");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let h = self.home.display();
|
||||
let content = format!(
|
||||
"{content}MODEL={h}/m.gguf\nBIN={h}/llama-server\nPORT={}\n",
|
||||
self.port
|
||||
);
|
||||
std::fs::write(dir.join("dense.env"), &content).unwrap();
|
||||
for k in self.go.config_map().keys() {
|
||||
self.go.del("config", k);
|
||||
}
|
||||
for (k, v) in loki_rs::presets::parse_env(&content) {
|
||||
self.go.set("config", &k, &v);
|
||||
}
|
||||
}
|
||||
|
||||
async fn call(&self, method: &str, path: &str, body: &str) -> (u16, Value) {
|
||||
let (code, b) = fakego::send(self.facade, method, path, &[], body).await;
|
||||
let v = if b.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&b)
|
||||
.unwrap_or_else(|_| panic!("illisible : {}", String::from_utf8_lossy(&b)))
|
||||
};
|
||||
(code.as_u16(), v)
|
||||
}
|
||||
|
||||
async fn status(&self) -> Value {
|
||||
self.call("GET", "/api/tune/status", "").await.1
|
||||
}
|
||||
|
||||
async fn wait(&self, what: &str, key: &str) -> Value {
|
||||
for _ in 0..6000 {
|
||||
let st = self.status().await;
|
||||
if st[key] != json!(true) {
|
||||
return st;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("{what} : ne finit pas");
|
||||
}
|
||||
|
||||
fn config(&self) -> HashMap<String, String> {
|
||||
["MODEL", "CTX", "UBATCH", "BATCH", "PORT"]
|
||||
.iter()
|
||||
.filter_map(|k| self.go.get("config", k).map(|v| (k.to_string(), v)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn trials(res: &Value) -> Vec<(String, String, String)> {
|
||||
res["trials"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|t| {
|
||||
(
|
||||
t["label"].as_str().unwrap_or("").to_string(),
|
||||
t["status"].as_str().unwrap_or("").to_string(),
|
||||
t["why"].as_str().unwrap_or("").to_string(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Les tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Une optimisation entière : vrai moteur arrêté puis relancé, essais
|
||||
/// composés à blanc puis lancés, mesurés, retenus ; puis l'application dans
|
||||
/// une copie, dans le preset, et le refus d'un preset changé depuis.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn optimisation_complete() {
|
||||
let tn = start().await;
|
||||
let (code, out) = tn
|
||||
.call("POST", "/api/tune", r#"{"stages":["lots"],"budget_min":5}"#)
|
||||
.await;
|
||||
assert_eq!((code, out), (202, json!({ "ok": true })));
|
||||
assert_eq!(*tn.go.lease_whys.lock().unwrap(), vec!["tune".to_string()]);
|
||||
// Pendant la mesure : le verrou de LOKI_HOME, au nom de la façade.
|
||||
let mut seen_lock = false;
|
||||
for _ in 0..2000 {
|
||||
if let Some(o) = loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")) {
|
||||
assert_eq!(o.owner.pid, i64::from(std::process::id()));
|
||||
assert_eq!(o.via, "web");
|
||||
assert!(o.main_was_active);
|
||||
seen_lock = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
assert!(seen_lock, "verrou jamais posé");
|
||||
let (code, out) = tn.call("POST", "/api/tune", "{}").await;
|
||||
assert_eq!(code, 409);
|
||||
assert_eq!(out["error"], json!(BUSY));
|
||||
let st = tn.wait("optimisation", "running").await;
|
||||
assert_eq!(st["error"], Value::Null, "{st}");
|
||||
let res = &st["result"];
|
||||
assert_eq!(res["preset_id"], json!("dense"));
|
||||
assert_eq!(res["preset_name"], json!("Dense"));
|
||||
assert_eq!(res["engine"], json!("b7000-faux"));
|
||||
assert_eq!(res["engine_build"], json!(7000));
|
||||
assert_eq!(
|
||||
(res["n_ctx"].clone(), res["slots"].clone()),
|
||||
(json!(32768), json!(1))
|
||||
);
|
||||
assert_eq!(res["depth"], json!(16384));
|
||||
assert_eq!(res["baseline"]["status"], json!("mesuré"));
|
||||
assert_eq!(res["baseline"]["runs"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(res["baseline"]["offloaded"], json!("33/33"));
|
||||
let tr = trials(res);
|
||||
assert_eq!(
|
||||
tr.iter().map(|t| t.0.as_str()).collect::<Vec<_>>(),
|
||||
[
|
||||
"UBATCH=512 BATCH=2048",
|
||||
"UBATCH=512 BATCH=1024",
|
||||
"UBATCH=1024 BATCH=2048",
|
||||
"UBATCH=2048 BATCH=4096"
|
||||
]
|
||||
);
|
||||
assert_eq!(tr[0].1, "retenu", "{tr:?}");
|
||||
assert!(
|
||||
tr[0].2.starts_with('−') && tr[0].2.contains("par tour (seuil"),
|
||||
"{tr:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
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}");
|
||||
let skipped: Vec<&str> = res["skipped"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.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"))
|
||||
);
|
||||
// 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"]);
|
||||
assert_eq!(*tn.go.dictate_stops.lock().unwrap(), 1);
|
||||
assert!(!tn.home.join("tune.lock").exists(), "verrou non rendu");
|
||||
assert!(tn.go.lease.lock().unwrap().is_none(), "bail non rendu");
|
||||
// Chaque essai a sa copie de configuration, sur une adresse privée ;
|
||||
// config.env n'est jamais touché.
|
||||
let e1 = loki_rs::presets::parse_env(
|
||||
&std::fs::read_to_string(tn.home.join("tune/run/e01/config.env")).unwrap(),
|
||||
);
|
||||
assert_eq!(e1.get("HOST").map(String::as_str), Some("127.0.0.1"));
|
||||
assert_ne!(e1.get("PORT"), Some(&tn.port.to_string()));
|
||||
assert_eq!(tn.config()["UBATCH"], "2048");
|
||||
// Enregistré par preset.
|
||||
let saved: Value = serde_json::from_str(&tn.go.get("state", "tune_results").unwrap()).unwrap();
|
||||
assert_eq!(saved["dense"]["best"], res["best"]);
|
||||
let (_, last) = tn.call("GET", "/api/tune/last?id=dense", "").await;
|
||||
assert_eq!(last["ok"], json!(true));
|
||||
assert_eq!(last["preset_changed"], json!(false));
|
||||
assert_eq!(last["result"], *res);
|
||||
|
||||
// Copie : rien d'autre ne bouge.
|
||||
let (code, out) = tn
|
||||
.call("POST", "/api/tune/apply", r#"{"target":"copy"}"#)
|
||||
.await;
|
||||
assert_eq!(code, 200, "{out}");
|
||||
let msg = out["message"].as_str().unwrap();
|
||||
assert!(
|
||||
msg.starts_with("copie « Dense (optimisé) » créée ("),
|
||||
"{msg}"
|
||||
);
|
||||
assert!(msg.ends_with(") — bascule dessus pour l'utiliser"), "{msg}");
|
||||
assert!(
|
||||
std::fs::read_to_string(tn.home.join("presets/dense.env"))
|
||||
.unwrap()
|
||||
.contains("UBATCH=2048")
|
||||
);
|
||||
assert_eq!(*tn.go.service_os.lock().unwrap(), ["stop", "start"]);
|
||||
|
||||
// Le preset lui-même : réécrit, appliqué, moteur redémarré et sondé.
|
||||
let (code, out) = tn
|
||||
.call("POST", "/api/tune/apply", r#"{"target":"preset"}"#)
|
||||
.await;
|
||||
assert_eq!((code, out), (202, json!({ "ok": true })));
|
||||
let st = tn.wait("application", "applying").await;
|
||||
assert_eq!(st["apply"]["error"], json!(""), "{st}");
|
||||
assert_eq!(
|
||||
st["apply"]["message"],
|
||||
json!("preset « Dense » optimisé et vérifié")
|
||||
);
|
||||
let log: Vec<&str> = st["apply"]["log"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|s| s.as_str().unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
log[0].starts_with("écriture du preset (sauvegarde : "),
|
||||
"{log:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
&log[1..],
|
||||
[
|
||||
"redémarrage du moteur",
|
||||
"attente du moteur",
|
||||
"sonde : prompt de 16384 jetons puis decode"
|
||||
]
|
||||
);
|
||||
let preset = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap();
|
||||
assert!(
|
||||
preset.contains("UBATCH=512") && preset.contains("# NAME=Dense"),
|
||||
"{preset}"
|
||||
);
|
||||
assert_eq!(tn.config()["UBATCH"], "512");
|
||||
assert_eq!(
|
||||
*tn.go.service_os.lock().unwrap(),
|
||||
["stop", "start", "restart"]
|
||||
);
|
||||
assert!(!tn.home.join("tune.lock").exists(), "verrou non rendu");
|
||||
// Le preset a changé depuis la mesure : plus rien ne s'applique.
|
||||
let (code, out) = tn
|
||||
.call("POST", "/api/tune/apply", r#"{"target":"preset"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
(code, out),
|
||||
(
|
||||
200,
|
||||
json!({ "ok": false, "error": "le preset a changé depuis la mesure : relance l'optimisation" })
|
||||
)
|
||||
);
|
||||
let (_, last) = tn.call("GET", "/api/tune/last?id=dense", "").await;
|
||||
assert_eq!(last["preset_changed"], json!(true));
|
||||
}
|
||||
|
||||
/// Une sonde qui échoue après application : l'ancienne version revient —
|
||||
/// preset ET configuration — et le moteur est redémarré dessus.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn application_defaite() {
|
||||
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}");
|
||||
// Le moteur relancé ne garde pas son contexte.
|
||||
*tn.main.n_ctx.lock().unwrap() = 4096;
|
||||
let (code, _) = tn
|
||||
.call("POST", "/api/tune/apply", r#"{"target":"preset"}"#)
|
||||
.await;
|
||||
assert_eq!(code, 202);
|
||||
let st = tn.wait("application", "applying").await;
|
||||
assert_eq!(
|
||||
st["apply"]["error"],
|
||||
json!("contexte 4096 × 1 slot(s) au lieu de 32768 × 1 : ancienne version rétablie")
|
||||
);
|
||||
assert_eq!(st["apply"]["message"], json!(""));
|
||||
assert_eq!(
|
||||
st["apply"]["log"].as_array().unwrap().last().unwrap(),
|
||||
"échec (contexte 4096 × 1 slot(s) au lieu de 32768 × 1) : retour à l'ancienne version"
|
||||
);
|
||||
let preset = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap();
|
||||
assert!(
|
||||
preset.contains("UBATCH=2048"),
|
||||
"preset non rétabli : {preset}"
|
||||
);
|
||||
assert_eq!(tn.config()["UBATCH"], "2048", "configuration non rétablie");
|
||||
assert_eq!(
|
||||
*tn.go.service_os.lock().unwrap(),
|
||||
["stop", "start", "restart", "restart"]
|
||||
);
|
||||
assert!(!tn.home.join("tune.lock").exists());
|
||||
// Une sauvegarde a été faite avant d'écrire.
|
||||
let backups: Vec<_> = std::fs::read_dir(tn.home.join("tune/backup"))
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.collect();
|
||||
assert_eq!(backups.len(), 1);
|
||||
}
|
||||
|
||||
/// Essais refusés à la composition, essais qui ne chargent pas, budget.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn essais_refuses() {
|
||||
let tn = start().await;
|
||||
tn.preset(&format!("{PRESET}REFUS=1024\nECHEC=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,
|
||||
[
|
||||
(
|
||||
"UBATCH=512 BATCH=2048".to_string(),
|
||||
"échec".to_string(),
|
||||
"mémoire insuffisante (OOM)".to_string()
|
||||
),
|
||||
(
|
||||
"UBATCH=512 BATCH=1024".into(),
|
||||
"échec".into(),
|
||||
"mémoire insuffisante (OOM)".into()
|
||||
),
|
||||
(
|
||||
"UBATCH=1024 BATCH=2048".into(),
|
||||
"écarté".into(),
|
||||
"refusé au lancement : [err] UBATCH=1024 refusé".into()
|
||||
),
|
||||
(
|
||||
"UBATCH=2048 BATCH=4096".into(),
|
||||
"mesuré".into(),
|
||||
tr[3].2.clone()
|
||||
),
|
||||
],
|
||||
"{tr:?}"
|
||||
);
|
||||
assert!(tr[3].2.ends_with(" (seuil 3.0 %)"), "{tr:?}");
|
||||
assert_eq!(st["result"]["best"], Value::Null);
|
||||
assert_eq!(st["result"]["set"], Value::Null);
|
||||
// Rien à appliquer.
|
||||
let (code, out) = tn
|
||||
.call("POST", "/api/tune/apply", r#"{"target":"preset"}"#)
|
||||
.await;
|
||||
assert_eq!(
|
||||
(code, out),
|
||||
(
|
||||
200,
|
||||
json!({ "ok": false, "error": "rien à appliquer : la configuration actuelle reste la meilleure" })
|
||||
)
|
||||
);
|
||||
|
||||
// La référence ne se compose pas : l'optimisation échoue, le moteur est
|
||||
// relancé, le verrou rendu.
|
||||
tn.preset(&format!("{PRESET}REFUS=2048\n"));
|
||||
tn.go.service_os.lock().unwrap().clear();
|
||||
tn.call("POST", "/api/tune", "{}").await;
|
||||
let st = tn.wait("référence", "running").await;
|
||||
assert_eq!(
|
||||
st["error"],
|
||||
json!("la configuration active ne se lance pas : [err] UBATCH=2048 refusé")
|
||||
);
|
||||
assert_eq!(st["result"], Value::Null);
|
||||
assert_eq!(*tn.go.service_os.lock().unwrap(), ["stop", "start"]);
|
||||
// La relance échoue elle aussi : dit dans l'erreur.
|
||||
*tn.go.service_os_fail.lock().unwrap() = Some(("start".into(), "boum".into()));
|
||||
tn.call("POST", "/api/tune", "{}").await;
|
||||
let st = tn.wait("relance", "running").await;
|
||||
assert_eq!(
|
||||
st["error"],
|
||||
json!(
|
||||
"la configuration active ne se lance pas : [err] UBATCH=2048 refusé — relance du moteur principal : boum"
|
||||
)
|
||||
);
|
||||
*tn.go.service_os_fail.lock().unwrap() = None;
|
||||
assert!(!tn.home.join("tune.lock").exists());
|
||||
}
|
||||
|
||||
/// Annulation pendant les essais : l'essai arrêté, le résultat partiel
|
||||
/// gardé.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn annulation() {
|
||||
let tn = start().await;
|
||||
tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await;
|
||||
// Attendre le premier essai, puis annuler.
|
||||
for _ in 0..4000 {
|
||||
let st = tn.status().await;
|
||||
if st["phase"]
|
||||
.as_str()
|
||||
.is_some_and(|p| p.starts_with("lots — "))
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
let (code, out) = tn.call("POST", "/api/tune/cancel", "").await;
|
||||
assert_eq!((code, out), (200, json!({ "ok": true })));
|
||||
let st = tn.wait("annulation", "running").await;
|
||||
assert_eq!(st["result"]["partial"], json!("annulé"), "{st}");
|
||||
assert!(!tn.home.join("tune.lock").exists());
|
||||
let (_, out) = tn.call("POST", "/api/tune/cancel", "").await;
|
||||
assert_eq!(out, json!({ "ok": false }));
|
||||
// Le stop du chat (signalé au renouvellement du bail) annule aussi.
|
||||
*tn.go.lease_stop.lock().unwrap() = true;
|
||||
tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await;
|
||||
let st = tn.wait("stop", "running").await;
|
||||
*tn.go.lease_stop.lock().unwrap() = false;
|
||||
let annule =
|
||||
st["result"]["partial"] == json!("annulé") || st["error"] == json!("optimisation annulée");
|
||||
assert!(annule, "{st}");
|
||||
}
|
||||
|
||||
/// Les refus d'entrée et les routes au repos, avec les messages du Go.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn refus_d_entree() {
|
||||
let tn = start().await;
|
||||
assert_eq!(
|
||||
tn.status().await,
|
||||
json!({ "ok": true, "running": false, "applying": false })
|
||||
);
|
||||
assert_eq!(
|
||||
tn.call("GET", "/api/tune", "").await,
|
||||
(
|
||||
405,
|
||||
json!({ "ok": false, "error": "méthode GET refusée : POST attendu" })
|
||||
)
|
||||
);
|
||||
assert_eq!(tn.call("GET", "/api/tune/cancel", "").await.0, 405);
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune/cancel", "").await,
|
||||
(200, json!({ "ok": false }))
|
||||
);
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune/apply", r#"{"target":"copy"}"#)
|
||||
.await,
|
||||
(
|
||||
400,
|
||||
json!({ "ok": false, "error": "aucun résultat à appliquer" })
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
tn.call("GET", "/api/tune/last?id=dense", "").await,
|
||||
(200, json!({ "ok": false }))
|
||||
);
|
||||
let refus = |code: u16, e: &str| (code, json!({ "ok": false, "error": e }));
|
||||
tn.go.set("config", "EXTERNAL", "1");
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune", "{}").await,
|
||||
refus(
|
||||
400,
|
||||
"optimisation indisponible : le preset actif est une API externe"
|
||||
)
|
||||
);
|
||||
tn.go.del("config", "EXTERNAL");
|
||||
tn.go.set("config", "ENGINE", "strata");
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune", "{}").await,
|
||||
refus(
|
||||
409,
|
||||
"optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre"
|
||||
)
|
||||
);
|
||||
tn.go.del("config", "ENGINE");
|
||||
tn.go.set("config", "CTX", "4096");
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune", "{}").await,
|
||||
refus(
|
||||
409,
|
||||
"aucun preset ne correspond à la configuration active : enregistre-la comme preset (ou bascule sur l'un d'eux) avant d'optimiser"
|
||||
)
|
||||
);
|
||||
tn.go.set("config", "CTX", "32768");
|
||||
*tn.go.tune_ctx_busy.lock().unwrap() =
|
||||
"2 job(s) d'arrière-plan du mode code tournent encore".into();
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune", "{}").await,
|
||||
refus(409, "2 job(s) d'arrière-plan du mode code tournent encore")
|
||||
);
|
||||
tn.go.tune_ctx_busy.lock().unwrap().clear();
|
||||
*tn.main.busy.lock().unwrap() = true;
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune", "{}").await,
|
||||
refus(
|
||||
409,
|
||||
"le moteur traite une requête (discussion, tâche ou client /v1) : réessaie une fois libre"
|
||||
)
|
||||
);
|
||||
*tn.main.busy.lock().unwrap() = false;
|
||||
*tn.go.go_busy.lock().unwrap() = Some("génération en cours".into());
|
||||
assert_eq!(
|
||||
tn.call("POST", "/api/tune", "{}").await,
|
||||
refus(409, "génération en cours")
|
||||
);
|
||||
*tn.go.go_busy.lock().unwrap() = None;
|
||||
// Une optimisation tenue par un autre processus vivant (« loki tune ») :
|
||||
// refus, et l'interface le voit.
|
||||
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();
|
||||
assert_eq!(tn.call("POST", "/api/tune", "{}").await, refus(409, BUSY));
|
||||
assert_eq!(tn.status().await["elsewhere"], json!("cli"));
|
||||
std::fs::remove_file(tn.home.join("tune.lock")).unwrap();
|
||||
assert_eq!(tn.status().await.get("elsewhere"), None);
|
||||
assert!(tn.go.lease_whys.lock().unwrap().iter().all(|w| w == "tune"));
|
||||
}
|
||||
|
||||
/// Un verrou périmé (Loki tué en plein essai) : l'essai orphelin est arrêté
|
||||
/// — identité complète vérifiée — avant que l'optimisation ne reprenne la
|
||||
/// place ; une application interrompue est défaite.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn orphelin_et_application_interrompue() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let tn = start().await;
|
||||
let mut child = std::process::Command::new("sleep")
|
||||
.arg("60")
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = i64::from(child.id());
|
||||
let (start, exe) = loki_rs::tune::lock::proc_identity(pid).unwrap();
|
||||
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 });
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
o[k] = v.clone();
|
||||
}
|
||||
std::fs::write(tn.home.join("tune.lock"), o.to_string()).unwrap();
|
||||
};
|
||||
// Identité qui ne concorde pas (PID recyclé) : on ne tue rien.
|
||||
write(
|
||||
json!({ "pid": pid, "start": format!("{start}x"), "exes": [exe] }),
|
||||
json!({}),
|
||||
);
|
||||
tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#)
|
||||
.await;
|
||||
tn.wait("essais", "running").await;
|
||||
assert!(
|
||||
child.try_wait().unwrap().is_none(),
|
||||
"processus tué sur la foi du seul PID"
|
||||
);
|
||||
// Identité complète : arrêté.
|
||||
write(
|
||||
json!({ "pid": pid, "start": start, "exes": [exe] }),
|
||||
json!({}),
|
||||
);
|
||||
tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#)
|
||||
.await;
|
||||
tn.wait("orphelin", "running").await;
|
||||
let mut gone = false;
|
||||
for _ in 0..300 {
|
||||
if child.try_wait().unwrap().is_some() {
|
||||
gone = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(gone, "essai orphelin toujours en vie");
|
||||
|
||||
// Application interrompue : la sauvegarde revient, preset et
|
||||
// configuration.
|
||||
let old = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap();
|
||||
let backup = tn.home.join("vieux.env");
|
||||
std::fs::write(&backup, &old).unwrap();
|
||||
tn.preset(&PRESET.replace("UBATCH=2048", "UBATCH=999"));
|
||||
write(
|
||||
Value::Null,
|
||||
json!({ "phase": "application", "backup": backup, "preset_id": "dense", "preset_name": "Dense" }),
|
||||
);
|
||||
tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#)
|
||||
.await;
|
||||
tn.wait("application interrompue", "running").await;
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(),
|
||||
old
|
||||
);
|
||||
assert_eq!(tn.config()["UBATCH"], "2048");
|
||||
}
|
||||
|
||||
/// Sans la façade pour l'optimiseur, ses routes partent au Go.
|
||||
#[tokio::test]
|
||||
async fn sans_la_facade() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
|
||||
for p in [
|
||||
"/api/tune",
|
||||
"/api/tune/status",
|
||||
"/api/tune/cancel",
|
||||
"/api/tune/apply",
|
||||
"/api/tune/last",
|
||||
] {
|
||||
let (c, b) = fakego::send(facade, "GET", p, &[], "").await;
|
||||
assert_eq!(c.as_u16(), 200);
|
||||
assert_eq!(String::from_utf8_lossy(&b), format!("go:{p}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
//! Lot 28 : la partie pure de l'optimiseur, rejouée sur le journal tiré du Go
|
||||
//! (`internal/loki/parity_tune_test.go` → `tests/parity_tune/core.json`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use loki_rs::bench::BenchResult;
|
||||
use loki_rs::tune::core::{self as tc, Cfg, Env, Knob, Launch, PerfRec};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn fixture() -> Value {
|
||||
serde_json::from_str(include_str!("parity_tune/core.json")).expect("journal")
|
||||
}
|
||||
|
||||
fn s(v: &Value) -> &str {
|
||||
v.as_str().unwrap_or("")
|
||||
}
|
||||
|
||||
fn cfg(v: &Value) -> Cfg {
|
||||
serde_json::from_value(if v.is_null() { json!({}) } else { v.clone() }).unwrap()
|
||||
}
|
||||
|
||||
fn strs(v: &Value) -> Vec<String> {
|
||||
serde_json::from_value(if v.is_null() { json!([]) } else { v.clone() }).unwrap()
|
||||
}
|
||||
|
||||
/// Une liste Go : `null` et `[]` se valent ici (tranche nil ou vide).
|
||||
fn list(v: Vec<String>) -> Value {
|
||||
json!(v)
|
||||
}
|
||||
|
||||
fn same_list(got: Vec<String>, want: &Value, what: &str) {
|
||||
assert_eq!(list(got), list(strs(want)), "{what}");
|
||||
}
|
||||
|
||||
const KNOBS: [(&str, Knob); 5] = [
|
||||
("BATCH", tc::KNOB_BATCH),
|
||||
("UBATCH", tc::KNOB_UBATCH),
|
||||
("THREADS", tc::KNOB_THREADS),
|
||||
("FIT_TARGET", tc::KNOB_FIT_TARGET),
|
||||
("SPEC_N_MAX", tc::KNOB_SPEC_N),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn extra_args_jeton_par_jeton() {
|
||||
let fx = fixture();
|
||||
for c in fx["extra"].as_array().unwrap() {
|
||||
let input = s(&c["in"]);
|
||||
let spans: Vec<Value> = tc::arg_spans(input)
|
||||
.into_iter()
|
||||
.map(|sp| json!([sp.start, sp.end, sp.val]))
|
||||
.collect();
|
||||
let want = if c["spans"].is_null() {
|
||||
json!([])
|
||||
} else {
|
||||
c["spans"].clone()
|
||||
};
|
||||
assert_eq!(Value::Array(spans), want, "spans {input:?}");
|
||||
same_list(
|
||||
loki_rs::models::split_args(input),
|
||||
&c["split"],
|
||||
&format!("split {input:?}"),
|
||||
);
|
||||
let ub = tc::KNOB_UBATCH.flags;
|
||||
let t = tc::KNOB_THREADS.flags;
|
||||
for (key, got) in [
|
||||
("set_ub", tc::extra_set(input, ub, "2048")),
|
||||
("drop_ub", tc::extra_set(input, ub, "")),
|
||||
("set_t", tc::extra_set(input, t, "7")),
|
||||
("set_t_space", tc::extra_set(input, t, "a b")),
|
||||
("set_b", tc::extra_set(input, tc::KNOB_BATCH.flags, "4096")),
|
||||
(
|
||||
"drop_ts",
|
||||
tc::extra_drop(input, 1, &["-ts", "--tensor-split"]),
|
||||
),
|
||||
(
|
||||
"drop_cmoe",
|
||||
tc::extra_drop(input, 0, &["-cmoe", "--cpu-moe"]),
|
||||
),
|
||||
("add_bs", tc::extra_add_switch(input, "--backend-sampling")),
|
||||
] {
|
||||
assert_eq!(got, s(&c[key]), "{key} {input:?}");
|
||||
}
|
||||
let (dropped, removed) = tc::extra_drop_experts(input);
|
||||
assert_eq!(dropped, s(&c["drop_experts"]), "drop_experts {input:?}");
|
||||
same_list(removed, &c["removed"], &format!("removed {input:?}"));
|
||||
let split = loki_rs::models::split_args(input);
|
||||
same_list(
|
||||
tc::core_args(&split, false),
|
||||
&c["core"],
|
||||
&format!("core {input:?}"),
|
||||
);
|
||||
same_list(
|
||||
tc::core_args(&split, true),
|
||||
&c["core_optin"],
|
||||
&format!("core opt-in {input:?}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reglages_par_cle_ou_par_drapeau() {
|
||||
let fx = fixture();
|
||||
for c in fx["knobs"].as_array().unwrap() {
|
||||
let base = cfg(&c["cfg"]);
|
||||
for (name, k) in KNOBS {
|
||||
assert_eq!(k.get(&base), s(&c["get"][name]), "get {name} {base:?}");
|
||||
assert_eq!(
|
||||
k.set(&base, "99"),
|
||||
cfg(&c["set"][name]),
|
||||
"set {name} {base:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
k.set(&base, ""),
|
||||
cfg(&c["unset"][name]),
|
||||
"unset {name} {base:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env(v: &Value) -> Env {
|
||||
let map = |x: &Value| -> HashMap<String, String> {
|
||||
serde_json::from_value(if x.is_null() { json!({}) } else { x.clone() }).unwrap()
|
||||
};
|
||||
Env {
|
||||
help: s(&v["help"]).to_string(),
|
||||
gpus: v["gpus"].as_i64().unwrap(),
|
||||
vram_mib: v["vram_mib"].as_i64().unwrap(),
|
||||
model_bytes: v["model_bytes"].as_i64().unwrap(),
|
||||
moe: v["moe"].as_bool().unwrap(),
|
||||
phys_cores: v["phys_cores"].as_i64().unwrap(),
|
||||
smi: v["smi"].as_bool().unwrap(),
|
||||
arg_env: map(&v["arg_env"]),
|
||||
user_env: map(&v["user_env"]),
|
||||
queues_env: v["queues_env"].as_bool().unwrap(),
|
||||
placement: v["placement"].as_bool().unwrap(),
|
||||
opt_in: v["opt_in"].as_bool().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn essais_de_chaque_etape() {
|
||||
let fx = fixture();
|
||||
let mut n = 0;
|
||||
for c in fx["cands"].as_array().unwrap() {
|
||||
let (base, e) = (cfg(&c["cfg"]), env(&c["env"]));
|
||||
let what = format!("{} / {}", s(&c["cfg_name"]), s(&c["env_name"]));
|
||||
assert_eq!(
|
||||
json!(tc::weights_on_cpu(&base, &e)),
|
||||
c["weights_on_cpu"],
|
||||
"{what} : poids sur CPU"
|
||||
);
|
||||
for (name, generate) in tc::STAGES {
|
||||
let (cands, why) = generate(&base, &e);
|
||||
let want = &c["stages"][name];
|
||||
assert_eq!(why, s(&want["why"]), "{what} / {name} : pourquoi");
|
||||
let got: Vec<Value> = cands
|
||||
.iter()
|
||||
.map(|x| json!({ "stage": x.stage, "label": x.label, "cfg": x.cfg }))
|
||||
.collect();
|
||||
assert_eq!(Value::Array(got), want["cands"], "{what} / {name} : essais");
|
||||
n += cands.len();
|
||||
}
|
||||
}
|
||||
assert!(n > 100, "{n} essais seulement");
|
||||
for c in fx["stage_wanted"].as_array().unwrap() {
|
||||
let only = strs(&c["only"]);
|
||||
assert_eq!(
|
||||
json!(tc::stage_wanted(s(&c["name"]), &only)),
|
||||
c["wanted"],
|
||||
"{} {only:?}",
|
||||
c["name"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garde_fous() {
|
||||
let fx = fixture();
|
||||
for c in fx["denature"].as_array().unwrap() {
|
||||
let name = s(&c["name"]);
|
||||
let b: Launch = serde_json::from_value(c["base"].clone()).unwrap();
|
||||
let k: Launch = serde_json::from_value(c["cand"].clone()).unwrap();
|
||||
assert_eq!(tc::denature(&b, &k, false), s(&c["denature"]), "{name}");
|
||||
assert_eq!(
|
||||
tc::denature(&b, &k, true),
|
||||
s(&c["denature_optin"]),
|
||||
"{name} opt-in"
|
||||
);
|
||||
assert_eq!(
|
||||
json!(tc::launch_key(&b) == tc::launch_key(&k)),
|
||||
c["same_key"],
|
||||
"{name} : même lancement"
|
||||
);
|
||||
}
|
||||
for c in fx["cfg_check"].as_array().unwrap() {
|
||||
let name = s(&c["name"]);
|
||||
let (b, k) = (cfg(&c["base"]), cfg(&c["cand"]));
|
||||
let err = |r: Result<(), String>| r.err().unwrap_or_default();
|
||||
assert_eq!(err(tc::cfg_check(&b, &k, false)), s(&c["check"]), "{name}");
|
||||
assert_eq!(
|
||||
err(tc::cfg_check(&b, &k, true)),
|
||||
s(&c["check_optin"]),
|
||||
"{name} opt-in"
|
||||
);
|
||||
same_list(tc::diff(&b, &k), &c["diff"], &format!("{name} : diff"));
|
||||
}
|
||||
let content = s(&fx["patch"]["content"]);
|
||||
let base = loki_rs::presets::parse_env(content);
|
||||
for c in fx["patch"]["cases"].as_array().unwrap() {
|
||||
let name = s(&c["name"]);
|
||||
let (out, err) = match tc::patch_preset(content, &base, &cfg(&c["win"])) {
|
||||
Ok(o) => (o, String::new()),
|
||||
Err(e) => (String::new(), e),
|
||||
};
|
||||
assert_eq!(
|
||||
(out.as_str(), err.as_str()),
|
||||
(s(&c["out"]), s(&c["error"])),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_d_un_essai() {
|
||||
let fx = fixture();
|
||||
for c in fx["logs"].as_array().unwrap() {
|
||||
let log = s(&c["log"]);
|
||||
let (off, total, kv) = tc::log_facts(log);
|
||||
assert_eq!(
|
||||
json!([off, total, kv]),
|
||||
json!([c["offloaded"], c["total"], c["kv"]]),
|
||||
"{log:?}"
|
||||
);
|
||||
assert_eq!(tc::load_failure(log), s(&c["failure"]), "{log:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_et_departage() {
|
||||
let fx = fixture();
|
||||
for c in fx["weights"].as_array().unwrap() {
|
||||
let recs: Vec<PerfRec> = serde_json::from_value(if c["recs"].is_null() {
|
||||
json!([])
|
||||
} else {
|
||||
c["recs"].clone()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(tc::weights_from(&recs)).unwrap(),
|
||||
c["weights"],
|
||||
"{}",
|
||||
c["name"]
|
||||
);
|
||||
}
|
||||
let w = tc::Weights {
|
||||
k: 2000.0,
|
||||
g: 600.0,
|
||||
f_cold: 0.05,
|
||||
from: String::new(),
|
||||
};
|
||||
for c in fx["runs"].as_array().unwrap() {
|
||||
let bench: Option<BenchResult> = serde_json::from_value(c["bench"].clone()).unwrap();
|
||||
let (run, err) = match tc::run_from(bench.as_ref(), &w) {
|
||||
Ok(r) => (r, String::new()),
|
||||
Err(e) => (tc::Run::default(), e),
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&run).unwrap(),
|
||||
c["run"],
|
||||
"{}",
|
||||
c["name"]
|
||||
);
|
||||
assert_eq!(err, s(&c["error"]), "{}", c["name"]);
|
||||
}
|
||||
let f = |v: &Value| -> Vec<f64> {
|
||||
serde_json::from_value(if v.is_null() { json!([]) } else { v.clone() }).unwrap()
|
||||
};
|
||||
for c in fx["wins"].as_array().unwrap() {
|
||||
let (b, k) = (f(&c["base"]), f(&c["cand"]));
|
||||
let (gain, need, ok) = tc::wins(&b, &k);
|
||||
let n = |k: &str| c[k].as_f64().unwrap();
|
||||
assert_eq!(
|
||||
(gain, need, ok, tc::spread(&b), tc::mean(&k)),
|
||||
(
|
||||
n("gain"),
|
||||
n("need"),
|
||||
c["wins"].as_bool().unwrap(),
|
||||
n("spread_base"),
|
||||
n("mean_cand")
|
||||
),
|
||||
"{b:?} {k:?}"
|
||||
);
|
||||
}
|
||||
for c in fx["worth_repeat"].as_array().unwrap() {
|
||||
assert_eq!(
|
||||
json!(tc::worth_repeat(
|
||||
&f(&c["base"]),
|
||||
c["first"].as_f64().unwrap()
|
||||
)),
|
||||
c["worth"],
|
||||
"{c}"
|
||||
);
|
||||
}
|
||||
for c in fx["headroom"].as_array().unwrap() {
|
||||
let gpus: Vec<(i64, i64)> = serde_json::from_value(if c["gpus"].is_null() {
|
||||
json!([])
|
||||
} else {
|
||||
c["gpus"].clone()
|
||||
})
|
||||
.unwrap();
|
||||
let visible: Vec<i64> = serde_json::from_value(if c["visible"].is_null() {
|
||||
json!([])
|
||||
} else {
|
||||
c["visible"].clone()
|
||||
})
|
||||
.unwrap();
|
||||
let got = tc::headroom(&gpus, &visible);
|
||||
assert_eq!(
|
||||
json!([got.unwrap_or(0), got.is_some()]),
|
||||
json!([c["free"], c["ok"]]),
|
||||
"{c}"
|
||||
);
|
||||
}
|
||||
for c in fx["visible"].as_array().unwrap() {
|
||||
let got = tc::visible_gpus(&HashMap::from([(
|
||||
"CUDA_VISIBLE_DEVICES".to_string(),
|
||||
s(&c["in"]).to_string(),
|
||||
)]));
|
||||
let want: Vec<i64> = serde_json::from_value(if c["out"].is_null() {
|
||||
json!([])
|
||||
} else {
|
||||
c["out"].clone()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(got, want, "{c}");
|
||||
}
|
||||
for c in fx["force_addr"].as_array().unwrap() {
|
||||
assert_eq!(
|
||||
list(tc::force_addr(&strs(&c["in"]), "127.0.0.1", "41234")),
|
||||
c["out"],
|
||||
"{c}"
|
||||
);
|
||||
}
|
||||
for c in fx["trial_config"].as_array().unwrap() {
|
||||
let (text, err) = match tc::trial_config_text(&cfg(&c["cfg"])) {
|
||||
Ok(t) => (t, String::new()),
|
||||
Err(e) => (String::new(), e),
|
||||
};
|
||||
assert_eq!(
|
||||
(text.as_str(), err.as_str()),
|
||||
(s(&c["text"]), s(&c["error"])),
|
||||
"{c}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Le verrou : le fichier écrit octet pour octet comme le Go, la clé de
|
||||
/// prise, les binaires comparés par leur nom.
|
||||
#[test]
|
||||
fn verrou_au_format_du_go() {
|
||||
use loki_rs::tune::lock::{Owner, held_key, same_exe};
|
||||
let fx = fixture();
|
||||
for c in fx["lock_owners"].as_array().unwrap() {
|
||||
let o: Owner = serde_json::from_str(s(&c["json"])).unwrap();
|
||||
assert_eq!(serde_json::to_string(&o).unwrap(), s(&c["json"]));
|
||||
assert_eq!(held_key(&o), s(&c["key"]));
|
||||
}
|
||||
for c in fx["same_exe"].as_array().unwrap() {
|
||||
assert_eq!(
|
||||
same_exe(s(&c["a"]), s(&c["b"])),
|
||||
c["same"].as_bool().unwrap(),
|
||||
"{c}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Le résultat : enregistré et montré comme le Go l'écrit.
|
||||
#[test]
|
||||
fn resultat_au_format_du_go() {
|
||||
use loki_rs::tune::job::TuneResult;
|
||||
let fx = fixture();
|
||||
for r in fx["results"].as_array().unwrap() {
|
||||
let res: TuneResult = serde_json::from_str(s(r)).unwrap();
|
||||
assert_eq!(serde_json::to_string(&res).unwrap(), s(r));
|
||||
}
|
||||
}
|
||||
|
||||
/// Après application : la configuration est-elle l'essai retenu ?
|
||||
#[test]
|
||||
fn application_conforme() {
|
||||
use loki_rs::tune::job::{TuneResult, applied_mismatch};
|
||||
let fx = fixture();
|
||||
let a = &fx["applied"];
|
||||
let res = TuneResult {
|
||||
base_fp: s(&a["base_fp"]).to_string(),
|
||||
set: serde_json::from_value(a["set"].clone()).unwrap(),
|
||||
old: serde_json::from_value(a["old"].clone()).unwrap(),
|
||||
..Default::default()
|
||||
};
|
||||
for c in a["cases"].as_array().unwrap() {
|
||||
assert_eq!(applied_mismatch(&cfg(&c["now"]), &res), s(&c["why"]), "{c}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user