diff --git a/README.md b/README.md index d9243b2..6815126 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ un reverse proxy (`loki.mondomaine.fr`), définir une clé `LOKI_TRUSTED_HOSTS`. En CLI dans le conteneur : `docker exec -it loki loki status` (aussi : -`logs`, `restart`, `config`, `bench`, `test`…). +`logs`, `restart`, `config`, `bench`, `tune`, `test`…). ### Données et persistance @@ -638,6 +638,86 @@ Ajoutées par ce fork : (échec certain), avec la raison en clair. VRAM inconnue : avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés. Rien ne change dans la ligne de commande. +- **Optimiseur sans perte** (`loki tune`, bouton **« Optimiser… »** dans + l'éditeur du preset en service ; rien ne tourne de soi-même, aucune clé de + config). Cherche, pour cette machine et ce build du moteur, les réglages + d'ordonnancement qui raccourcissent un tour de conversation, sans rien changer + à ce que calcule le modèle (sortie équivalente en distribution : les sommes + flottantes ne sont pas identiques au bit près d'un lot ou d'un placement à + l'autre ; la spéculation vérifie chaque jeton). **Isolation** : le vrai moteur + est arrêté (comme « décharger la VRAM »), chaque essai est une **copie** de la + configuration avec ses surcharges dans `LOKI_HOME/tune/run/` — `config.env` + n'est jamais écrit — lancée par `loki serve` sur `127.0.0.1` et un port libre, + mêmes `CUDA_VISIBLE_DEVICES` ; son groupe de processus est tué en sortie + quoi qu'il arrive, puis le vrai moteur est relancé. **Verrou** exclusif entre + processus (`LOKI_HOME/tune.lock`, PID + instant de démarrage + binaire) : + pendant la mesure, tout démarrage du moteur (`serviceAction`, toutes + plateformes), bascule ou enregistrement de preset, choix des GPU, clé d'API, + mise à jour ou recompilation du moteur, rechargement de la VRAM, bench, chat, + compaction, `bash_bg` et clients `/v1` (503) sont refusés avec une phrase + claire ; les tâches planifiées attendent. Un verrou dont le propriétaire est + mort (Loki tué en plein essai) est écarté avant tout démarrage du moteur : + l'essai orphelin n'est arrêté que si PID, instant de démarrage ET binaire + concordent (`taskkill /T` sous Windows), et le moteur est relancé au + démarrage de l'interface s'il tournait avant. **Refus d'entrée** : preset + externe, aucun preset actif, génération, tâche, bench ou job `bash_bg` en + cours, moteur occupé (`/slots`) — en ligne de commande, seul ce dernier + contrôle voit le processus web : préférer le bouton. **Essais** (descente étape par étape depuis + la meilleure configuration du moment, chaque axe seulement si l'aide du + moteur et la machine le permettent) : placement — seulement avec « inclure + le placement » / `--placement` : `--fit` à la place des experts placés à la + main (`-ot …exps…`, `--n-cpu-moe`, `--cpu-moe`) ou d'un `--tensor-split` ; + marges `FIT_TARGET` (2 cartes ou plus, `--fit-target` dans l'aide) ; + `UBATCH` ∈ {512, 1024, 2048, + 4096 pour un MoE} × `BATCH` ∈ {max(2048, ub), + 2 × ub ≤ 8192}, lot > micro-lot sur 2 cartes tout GPU, jamais de micro-lot + plus grand sans `nvidia-smi` ; `OP_OFFLOAD_MIN_BATCH` ∈ {32, 128, 512} et + threads (omis = cœurs physiques, physiques − 1, moitié ; Linux) seulement + avec des poids sur CPU ; `CUDA_LAUNCH_QUEUES` off/4x sur 2 cartes ou plus ; + avec « inclure les options opt-in » / `--opt-in` : `SPEC=auto`, + `SPEC_N_MAX` ∈ {2, 3, 4}, `CUDA_GRAPH_OPT=on`, `--backend-sampling`. Un + réglage qu'`EXTRA_ARGS` écraserait (`-ub`, `-t`, `-fitt`…) est réglé **dans + EXTRA_ARGS, sur place** ; EXTRA_ARGS est traité jeton par jeton et seuls les + drapeaux de la liste blanche (`-ot` des experts, `-ncmoe`, `-cmoe`, `-sm`, + `-ts`, `-mg`, `-fit`, `-fitt`, `-b`, `-ub`, `-t`, `-tb`, + `--spec-draft-n-max` + et `-bs` en opt-in) bougent ; tous les autres jetons (`--cache-type-k`, + `--flash-attn`, `--chat-template-file`…) ressortent à l'octet près. **Jamais + touchés** : `MODEL`, `CTX`, `KV_TYPE*` (un cache déjà quantifié est signalé + zone grise, jamais modifié), `MMPROJ`, `REASONING*`, `TEMP` et autres clés + d'échantillonnage, `PARALLEL`, `NGL`, `SPLIT_MODE`, `CUDA_VISIBLE_DEVICES`. + **Garde-fous** : chaque essai est d'abord composé à blanc (`loki serve` sans + rien charger) et sa ligne de commande comparée à celle de référence hors + réglages permis — toute autre différence (contexte, cache KV, flash-attn, + slots, variable du moteur…) l'écarte comme « dénature » ; une fois chargé, + `n_ctx`, `total_slots` (`/props`) et les types du cache KV (journal) sont + recomparés ; moins de couches déportées que la référence = « fit en recul » + (performance), écarté ; moins de 768 Mio de VRAM libre sur une carte après la + mesure (+ 512 avec `MMPROJ`) = « trop juste », jamais retenu ; échec de + chargement ou OOM = essai ignoré ; deux essais à la même ligne de commande ne + sont mesurés qu'une fois. **Mesure** : le bench complet (prefill à froid à une + profondeur D fixe pour tous les essais, trois tours qui reprennent le cache — + points de reprise réels d'un hybride compris —, decode à D, ligne courte en + prose), avec l'échantillonnage et le raisonnement du preset. **Score** : durée + d'un tour type = K / prefill des tours + G / decode à D + f × D / prefill à + froid, K, G et f tirés des médianes de la télémétrie (`/api/perf/summary`) + quand elle en a vu assez, sinon 2000, 600 et 5 %. La référence est mesurée + deux fois ; un essai ne gagne que s'il bat la meilleure configuration du + moment de plus de max(3 %, écart entre passages) sur **deux** passages, sans + ralentir de plus de 5 % le decode en prose. Budget par défaut 30 min + (`--budget`), ETA et annulation ; au-delà, résultat partiel dit tel ; étapes de + base à la carte (`--stages lots,threads…`, cases de la fenêtre). **Résultat** + : les mesures de chaque essai et le diff des clés, enregistrés par preset + avec l'empreinte et le build du moteur (l'éditeur propose de relancer après + une mise à jour du moteur). **Rien n'est écrit sans un clic** : « enregistrer + dans une copie » (preset « (optimisé) », non activé) ou « appliquer à ce + preset » après confirmation du diff — et une confirmation de plus si + EXTRA_ARGS est réécrit par le placement. Le preset est sauvegardé + (`LOKI_HOME/tune/backup/`), réécrit clé par clé (commentaires et ordre + intacts), réappliqué ; la configuration active doit alors être exactement la + référence plus les clés réglées, le moteur redémarre et doit répondre avec le + même contexte et les mêmes slots puis tenir une sonde (prompt de D jetons et + decode) — sinon l'ancienne version est rétablie et le moteur relancé. Pendant + la mesure, le chat est indisponible : prévoir 10 à 40 minutes selon la taille + du modèle (un modèle relu depuis un disque lent recharge à chaque essai). - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_config.go b/internal/loki/backend_config.go index 7784fc8..653f195 100644 --- a/internal/loki/backend_config.go +++ b/internal/loki/backend_config.go @@ -92,6 +92,9 @@ func maskAPIKey(k string) string { // loki set-api-key génère une clé aléatoire // loki set-api-key "" supprime la protection func cmdSetAPIKey(args []string) error { + if err := tuneGuard(); err != nil { + return err + } var key string switch { case len(args) == 0: diff --git a/internal/loki/backend_gpu.go b/internal/loki/backend_gpu.go index 8fea0ff..68e34a7 100644 --- a/internal/loki/backend_gpu.go +++ b/internal/loki/backend_gpu.go @@ -148,6 +148,10 @@ func gpuList() error { // gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a // restart so the change takes effect. func gpuSet(value string) error { + // La sélection GPU relance le moteur : pas pendant une optimisation. + if err := tuneGuard(); err != nil { + return err + } if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil { return err } diff --git a/internal/loki/backend_llamacpp.go b/internal/loki/backend_llamacpp.go index fd62cab..c6e94aa 100644 --- a/internal/loki/backend_llamacpp.go +++ b/internal/loki/backend_llamacpp.go @@ -217,6 +217,9 @@ func llamacppInstall(args []string) error { // --------------------------------------------------------------------------- func llamacppUpdate(args []string) error { + if err := tuneGuard(); err != nil { + return err + } ref := "" clean := false noRestart := false diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 2a268b4..6c76c5b 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -267,6 +267,9 @@ func presetImposesEngine(p string) bool { // SwitchToPreset installe le preset et redémarre le service. Les réglages // « appareil » (preservedKeys) sont conservés à travers la bascule. func SwitchToPreset(target string) error { + if err := tuneGuard(); err != nil { + return err + } // SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant // l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien // n'y dit ce que porte le slot) : nil, rien ne se passe. diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 667a6e4..030d369 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -260,6 +260,18 @@ func downgradeLoadMode(args []string) ([]string, string) { // vérifie en table de tests plutôt qu'en relançant un moteur. func cmdServe(args []string) error { cfg := ReadConfig() + // Moteur d'ESSAI de l'optimiseur (backend_tune_run.go) : sa configuration + // est une copie posée dans le dossier de l'essai, jamais config.env, et rien + // de ce que le vrai moteur laisse derrière lui (état des slots, jeton de + // SPEC=auto) n'est touché. Sans la variable, rien ne change. + trialDir := os.Getenv(tuneTrialEnv) + if trialDir != "" { + c, err := readTuneTrialConfig(trialDir) + if err != nil { + return err + } + cfg = c + } bin := cfg["BIN"] if bin == "" { return fmt.Errorf("BIN non défini — lance « loki edit »") @@ -367,7 +379,12 @@ func cmdServe(args []string) error { } probeSlotPersistEnv(cfg, &si) if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") { - si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg)) + if trialDir != "" { + // L'essai a son propre dossier : celui du vrai moteur garde ses états. + si.SlotDir = prepareSlotDir(trialDir, false) + } else { + si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg)) + } } llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si) @@ -377,10 +394,14 @@ func cmdServe(args []string) error { // poids sur CPU…) : ceux des autres presets restent, pour leur retour. // Clé retirée : plus aucun état gardé sur le disque. persistKey := "" - if ok, _ := slotPersistPlan(cfg, extra, si); ok { + persistOK, _ := slotPersistPlan(cfg, extra, si) + switch { + case trialDir != "": + // Un essai ne garde rien et n'efface rien. + case persistOK: persistKey = slotPersistKey(llmArgs, env, si, bin) slotPersistPrune(si.SlotDir) - } else if !slotPersistOn(cfg) { + case !slotPersistOn(cfg): slotPersistPurge(filepath.Join(LokiHome(), "slots")) } for _, n := range notes { @@ -392,6 +413,21 @@ func cmdServe(args []string) error { return fmt.Errorf("%s", refuse) } + // Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur, + // même si EXTRA_ARGS en nomme d'autres (le dernier --port gagne). La ligne + // composée est rendue à l'optimiseur, qui la compare à celle de référence + // AVANT de charger quoi que ce soit ; à blanc, on s'arrête là. + if trialDir != "" { + llmArgs = tuneForceAddr(llmArgs, cfg["HOST"], cfg["PORT"]) + l := tuneLaunch{Bin: bin, Args: llmArgs, Env: env, Notes: notes, Probe: tuneProbeFrom(si)} + if err := writeTuneLaunch(trialDir, l); err != nil { + return err + } + if os.Getenv(tuneDryRunEnv) != "" { + return nil + } + } + // Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj // mmproj-F16.gguf) still resolve. _ = os.Chdir(LokiHome()) @@ -410,14 +446,16 @@ func cmdServe(args []string) error { // Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau // posé, au dernier moment — port libre, l'ancien moteur est donc bien parti // et le process web ne peut pas le confondre avec lui. - if spec == "auto" { + if spec == "auto" && trialDir == "" { _, _, auto := specArgs(cfg, extra, si) specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto) } // Posée au dernier moment, port libre : l'ancien moteur est parti, la clé // lue par le process web est celle du moteur qui répondra. - writeSlotPersistMarker(LokiHome(), persistKey) + if trialDir == "" { + writeSlotPersistMarker(LokiHome(), persistKey) + } fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n", bin, filepath.Base(model), port) diff --git a/internal/loki/backend_tune.go b/internal/loki/backend_tune.go new file mode 100644 index 0000000..e371df7 --- /dev/null +++ b/internal/loki/backend_tune.go @@ -0,0 +1,1324 @@ +package loki + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// Optimiseur sans perte (« loki tune », bouton « Optimiser » de l'éditeur de +// preset) — la partie PURE : ce qu'on a le droit de changer, les essais à +// tenter, la comparaison des lignes de commande, le score, la réécriture du +// preset. Le déroulé (moteur d'essai, mesures, verrou) vit dans +// backend_tune_run.go. +// +// Principe : chercher, pour CETTE machine et CE build du moteur, le placement, +// les lots, les threads (et, sur demande, la spéculation) qui rendent un tour de +// conversation le plus court — sans rien changer à ce que calcule le modèle. +// Seuls des réglages d'ordonnancement bougent : la sortie reste équivalente en +// distribution (les sommes flottantes ne sont pas identiques au bit près d'un +// lot, d'un placement ou d'un nombre de threads à l'autre — c'est déjà le cas +// entre deux tailles de prompt) ; la spéculation vérifie chaque jeton par le +// modèle, avec son propre échantillonnage. +// +// Jamais touchés : modèle et quantification, contexte, types du cache KV +// (même déjà quantifié : c'est alors signalé, jamais modifié), projecteur +// vision, raisonnement, échantillonnage, nombre de slots, et tout drapeau +// d'EXTRA_ARGS hors de la liste blanche. Un essai dont la ligne de commande +// diffère de la référence ailleurs que sur les drapeaux réglés est écarté +// (« dénature ») avant même d'être chargé. + +const ( + tuneTrialEnv = "LOKI_TUNE_TRIAL" // dossier de l'essai : « loki serve » y lit sa configuration + tuneDryRunEnv = "LOKI_TUNE_DRYRUN" // « loki serve » compose la ligne de commande, l'écrit, et s'arrête +) + +// tuneKeys : les clés du preset que l'optimiseur peut écrire. Toute autre clé +// reste identique à l'octet près (tuneCfgCheck). +var tuneKeys = map[string]bool{ + "BATCH": true, "UBATCH": true, "THREADS": true, "THREADS_BATCH": true, + "FIT_TARGET": true, "OP_OFFLOAD_MIN_BATCH": true, "CUDA_LAUNCH_QUEUES": true, + "EXTRA_ARGS": true, // jeton par jeton, liste blanche seulement (tuneFlags) +} + +// tuneOptInKeys : seulement si l'utilisateur coche « inclure les options +// opt-in » — décodage spéculatif et graphes CUDA, eux-mêmes opt-in. +var tuneOptInKeys = map[string]bool{"SPEC": true, "SPEC_N_MAX": true, "CUDA_GRAPH_OPT": true} + +// tuneNeverKeys : ce que l'optimiseur ne touche jamais. Documentaire, et vérifié +// par les tests : aucune n'est dans tuneKeys ni tuneOptInKeys. +var tuneNeverKeys = []string{"MODEL", "CTX", "KV_TYPE", "KV_TYPE_K", "KV_TYPE_V", "MMPROJ", + "REASONING", "REASONING_BUDGET", "REASONING_EFFORT", "REASONING_PRESERVE", "REASONING_ECHO", + "TEMP", "TOP_P", "TOP_K", "MIN_P", "PRESENCE_PENALTY", "REPEAT_PENALTY", + "PARALLEL", "SIDE_SLOT", "MODEL_DRAFT", "SPEC_SAMPLING", "NGL", "SPLIT_MODE", + "CUDA_VISIBLE_DEVICES", "BIN", "HOST", "PORT"} + +// tuneFlag : un drapeau d'EXTRA_ARGS que l'optimiseur peut retirer ou régler. +type tuneFlag struct { + names []string + arity int // 0 = interrupteur, 1 = une valeur + optIn bool // seulement avec « inclure les options opt-in » +} + +// tuneFlags : la liste blanche. Placement des experts (-ot : seulement les +// éléments qui visent les experts), découpe entre cartes, --fit, lots, +// threads ; spéculation et échantillonnage sur GPU en opt-in. +var tuneFlags = []tuneFlag{ + {names: []string{"-ot", "--override-tensor"}, arity: 1}, + {names: []string{"-ncmoe", "--n-cpu-moe"}, arity: 1}, + {names: []string{"-cmoe", "--cpu-moe"}}, + {names: []string{"-sm", "--split-mode"}, arity: 1}, + {names: []string{"-ts", "--tensor-split"}, arity: 1}, + {names: []string{"-mg", "--main-gpu"}, arity: 1}, + {names: []string{"-fit", "--fit"}, arity: 1}, + {names: []string{"-fitt", "--fit-target"}, arity: 1}, + {names: []string{"-b", "--batch-size"}, arity: 1}, + {names: []string{"-ub", "--ubatch-size"}, arity: 1}, + {names: []string{"-t", "--threads"}, arity: 1}, + {names: []string{"-tb", "--threads-batch"}, arity: 1}, + {names: []string{"--spec-draft-n-max", "--draft-max", "--draft-n"}, arity: 1, optIn: true}, + {names: []string{"-bs", "--backend-sampling"}, optIn: true}, +} + +// tuneForbiddenFlags : jamais ajoutés, retirés ni modifiés — copiés tels quels, +// dans l'ordre. Documentaire et testé : aucun n'est dans tuneFlags. +var tuneForbiddenFlags = []string{"-ctk", "--cache-type-k", "-ctv", "--cache-type-v", "-fa", "--flash-attn", + "-c", "--ctx-size", "--temp", "--top-k", "--top-p", "--min-p", "--samplers", "--repeat-penalty", + "--repeat-last-n", "--dry-multiplier", "--reasoning", "--reasoning-budget", "--reasoning-format", + "--chat-template", "--chat-template-file", "--jinja", "--cache-reuse", "--context-shift", "--swa-full", + "-kvu", "--kv-unified", "--keep", "-np", "--parallel", "-m", "--model", "--mmproj", "-ngl", "--n-gpu-layers"} + +// tuneFlagFor : l'entrée de la liste blanche pour ce nom, ou nil. +func tuneFlagFor(name string, optIn bool) *tuneFlag { + for i := range tuneFlags { + f := &tuneFlags[i] + if f.optIn && !optIn { + continue + } + for _, n := range f.names { + if n == name { + return f + } + } + } + return nil +} + +// --- EXTRA_ARGS jeton par jeton ---------------------------------------------- + +// argSpan : un argument d'EXTRA_ARGS et sa place exacte dans la chaîne +// (guillemets compris). Les réécritures coupent et recollent la chaîne +// d'origine : un jeton hors liste blanche ressort à l'octet près, guillemets et +// espacement compris. +type argSpan struct { + start, end int + val string +} + +// argSpans découpe comme splitArgs (mêmes règles de guillemets, mêmes jetons), +// en gardant les positions. Les tests vérifient l'égalité des deux découpages. +func argSpans(s string) []argSpan { + var out []argSpan + var cur strings.Builder + var quote rune + start := -1 + flush := func(end int) { + if cur.Len() > 0 { + out = append(out, argSpan{start, end, cur.String()}) + cur.Reset() + } + start = -1 + } + for i, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + if cur.Len() == 0 { + // « "" » : un argument vide explicite, comme splitArgs. + out = append(out, argSpan{start, i + 1, ""}) + start = -1 + } + } else { + cur.WriteRune(r) + } + case r == '"' || r == '\'': + if start < 0 { + start = i + } + quote = r + case r == ' ' || r == '\t' || r == '\n' || r == '\r': + flush(i) + default: + if start < 0 { + start = i + } + cur.WriteRune(r) + } + } + flush(len(s)) + return out +} + +// extraFlagSpan : une occurrence d'un drapeau, avec sa valeur éventuelle. +type extraFlagSpan struct { + name string + flag argSpan + value *argSpan // nil : interrupteur, « nom=valeur », ou valeur manquante + eqVal string // valeur de la forme « nom=valeur » + hasEq bool + tokens []argSpan // les jetons qui la composent +} + +// extraFlags repère, dans EXTRA_ARGS, les occurrences des drapeaux nommés. +// arity : 1 si le drapeau prend une valeur. +func extraFlags(spans []argSpan, arity int, names ...string) []extraFlagSpan { + var out []extraFlagSpan + for i := 0; i < len(spans); i++ { + name, val, hasEq := strings.Cut(spans[i].val, "=") + hit := false + for _, n := range names { + if name == n { + hit = true + } + } + if !hit { + continue + } + f := extraFlagSpan{name: name, flag: spans[i], eqVal: val, hasEq: hasEq, tokens: []argSpan{spans[i]}} + if arity == 1 && !hasEq && i+1 < len(spans) { + v := spans[i+1] + f.value = &v + f.tokens = append(f.tokens, v) + i++ + } + out = append(out, f) + } + return out +} + +// spliceEdit : un morceau de la chaîne d'origine remplacé (repl vide = retiré). +type spliceEdit struct { + start, end int + repl string +} + +// applyEdits applique des remplacements disjoints. Un retrait emporte les +// blancs qui le précèdent (ou, en tête de chaîne, ceux qui le suivent) : pas de +// double espace à la place d'un drapeau retiré. Le reste n'est pas touché. +func applyEdits(s string, edits []spliceEdit) string { + if len(edits) == 0 { + return s + } + sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start }) + var b strings.Builder + last := 0 + for _, e := range edits { + start, end := e.start, e.end + if e.repl == "" { + ws := start + for ws > last && (s[ws-1] == ' ' || s[ws-1] == '\t') { + ws-- + } + if ws == start || ws == 0 { + for end < len(s) && (s[end] == ' ' || s[end] == '\t') { + end++ + } + if ws == 0 { + start = 0 + } + } else { + start = ws + } + } + if start < last { + start = last + } + b.WriteString(s[last:start]) + b.WriteString(e.repl) + last = end + } + b.WriteString(s[last:]) + return strings.TrimSpace(b.String()) +} + +// quoteArg : un argument tel que splitArgs le relira d'un tenant. +func quoteArg(a string) string { return joinArgs([]string{a}) } + +// tuneExtraSet règle un drapeau à valeur dans EXTRA_ARGS : la DERNIÈRE +// occurrence (celle que retient le moteur) voit sa valeur remplacée sur place ; +// absent, le drapeau est ajouté en fin de chaîne sous son premier nom. val vide +// retire toutes les occurrences. +func tuneExtraSet(s string, names []string, val string) string { + occ := extraFlags(argSpans(s), 1, names...) + if val == "" { + var edits []spliceEdit + for _, o := range occ { + for _, t := range o.tokens { + edits = append(edits, spliceEdit{t.start, t.end, ""}) + } + } + return applyEdits(s, edits) + } + if len(occ) == 0 { + add := names[0] + " " + quoteArg(val) + if strings.TrimSpace(s) == "" { + return add + } + return strings.TrimRight(s, " \t") + " " + add + } + o := occ[len(occ)-1] + switch { + case o.hasEq: + return applyEdits(s, []spliceEdit{{o.flag.start, o.flag.end, o.name + "=" + quoteArg(val)}}) + case o.value != nil: + return applyEdits(s, []spliceEdit{{o.value.start, o.value.end, quoteArg(val)}}) + default: + return s[:o.flag.end] + " " + quoteArg(val) + s[o.flag.end:] + } +} + +// tuneExtraDrop retire toutes les occurrences des drapeaux (valeur comprise). +func tuneExtraDrop(s string, arity int, names ...string) string { + var edits []spliceEdit + for _, o := range extraFlags(argSpans(s), arity, names...) { + for _, t := range o.tokens { + edits = append(edits, spliceEdit{t.start, t.end, ""}) + } + } + return applyEdits(s, edits) +} + +// tuneExtraAddSwitch ajoute un interrupteur absent (--backend-sampling). +func tuneExtraAddSwitch(s, name string) string { + if strings.TrimSpace(s) == "" { + return name + } + return strings.TrimRight(s, " \t") + " " + name +} + +// tuneExtraDropExperts retire le placement MANUEL des experts : les éléments +// d'un -ot qui visent les experts (les autres restent, sur place), --n-cpu-moe, +// --cpu-moe. Rend aussi ce qui a été retiré, pour le dire. +func tuneExtraDropExperts(s string) (string, []string) { + spans := argSpans(s) + var edits []spliceEdit + var removed []string + for _, o := range extraFlags(spans, 1, "-ot", "--override-tensor") { + v := o.eqVal + if !o.hasEq { + if o.value == nil { + continue + } + v = o.value.val + } + var rest []string + hit := false + for _, el := range strings.Split(v, ",") { + el = strings.TrimSpace(el) + pat, _, _ := strings.Cut(el, "=") + switch { + case el == "": + case otTargetsExperts(pat): + removed = append(removed, o.name+" "+el) + hit = true + default: + rest = append(rest, el) + } + } + if !hit { + continue // rien visant les experts : intouché + } + switch { + case len(rest) == 0: + for _, t := range o.tokens { + edits = append(edits, spliceEdit{t.start, t.end, ""}) + } + case o.hasEq: + edits = append(edits, spliceEdit{o.flag.start, o.flag.end, o.name + "=" + quoteArg(strings.Join(rest, ","))}) + default: + edits = append(edits, spliceEdit{o.value.start, o.value.end, quoteArg(strings.Join(rest, ","))}) + } + } + for _, o := range extraFlags(spans, 1, "-ncmoe", "--n-cpu-moe") { + v := o.eqVal + if o.value != nil { + v = o.value.val + } + removed = append(removed, o.name+" "+v) + for _, t := range o.tokens { + edits = append(edits, spliceEdit{t.start, t.end, ""}) + } + } + for _, o := range extraFlags(spans, 0, "-cmoe", "--cpu-moe") { + removed = append(removed, o.name) + edits = append(edits, spliceEdit{o.flag.start, o.flag.end, ""}) + } + return applyEdits(s, edits), removed +} + +// --- réglages : par la clé, ou par le drapeau qui l'écrase --------------------- + +// tuneKnob : une valeur réglable, par sa clé du preset ou par le drapeau +// d'EXTRA_ARGS qui l'emporterait sur elle. Écrire la clé quand EXTRA_ARGS porte +// déjà le drapeau ne changerait rien au moteur — et l'essai mesurerait autre +// chose que ce que le preset appliqué reproduira. On règle donc le drapeau, sur +// place. +type tuneKnob struct { + key string + flags []string +} + +var ( + knobBatch = tuneKnob{"BATCH", []string{"-b", "--batch-size"}} + knobUBatch = tuneKnob{"UBATCH", []string{"-ub", "--ubatch-size"}} + knobThreads = tuneKnob{"THREADS", []string{"-t", "--threads"}} + knobFitTarget = tuneKnob{"FIT_TARGET", []string{"-fitt", "--fit-target"}} + knobSpecN = tuneKnob{"SPEC_N_MAX", []string{"--spec-draft-n-max"}} +) + +// get : la valeur effective (drapeau d'EXTRA_ARGS, sinon la clé). +func (k tuneKnob) get(cfg map[string]string) string { + if v := flagValue(splitArgs(cfg["EXTRA_ARGS"]), k.flags...); v != "" { + return v + } + return strings.TrimSpace(cfg[k.key]) +} + +// set rend une COPIE de cfg où la valeur vaut val ("" = retirée). +func (k tuneKnob) set(cfg map[string]string, val string) map[string]string { + out := copyCfg(cfg) + if hasAnyFlag(splitArgs(cfg["EXTRA_ARGS"]), k.flags...) { + out["EXTRA_ARGS"] = tuneExtraSet(cfg["EXTRA_ARGS"], k.flags, val) + if out["EXTRA_ARGS"] == "" { + delete(out, "EXTRA_ARGS") + } + return out + } + if val == "" { + delete(out, k.key) + } else { + out[k.key] = val + } + return out +} + +func copyCfg(cfg map[string]string) map[string]string { + out := make(map[string]string, len(cfg)) + for k, v := range cfg { + out[k] = v + } + return out +} + +// setKey : copie de cfg avec key = val ("" = retirée). +func setKey(cfg map[string]string, key, val string) map[string]string { + out := copyCfg(cfg) + if val == "" { + delete(out, key) + } else { + out[key] = val + } + return out +} + +// --- les essais ------------------------------------------------------------------ + +// tuneEnv : ce que l'optimiseur sait de la machine et de ce que l'utilisateur +// a coché. +type tuneEnv struct { + Help string + GPUs int // cartes visibles par le moteur ; 0 = inconnu + VRAMMiB int64 // VRAM totale des cartes visibles ; 0 = inconnue + ModelBytes int64 + MoE bool // le GGUF annonce des experts + PhysCores int // cœurs physiques ; 0 = inconnu (axe threads sauté) + SMI bool // VRAM libre mesurable par carte (nvidia-smi) + ArgEnv map[string]string // LLAMA_ARG_* de l'environnement + UserEnv map[string]string // GGML_* déjà posés (intouchés) + QueuesEnv bool // CUDA_SCALE_LAUNCH_QUEUES déjà posé + Placement bool // « inclure le placement » : EXTRA_ARGS réécrit pour --fit + OptIn bool // « inclure les options opt-in » +} + +// tuneCand : un essai — la configuration COMPLÈTE à lancer (copie + surcharges). +type tuneCand struct { + Stage string `json:"stage"` + Label string `json:"label"` + Cfg map[string]string `json:"-"` +} + +// tuneStage : une étape de la recherche, appliquée à la meilleure configuration +// du moment (descente coordonnée). why : pourquoi l'étape est sautée. +type tuneStage struct { + Name string + Gen func(cfg map[string]string, env tuneEnv) (cands []tuneCand, why string) +} + +// tuneStages : l'ordre de la recherche. Le placement d'abord (il décide de ce +// qui reste en RAM, donc de l'intérêt des étapes suivantes), puis les lots, +// puis ce qui ne compte que pour des poids sur CPU, puis l'opt-in. +var tuneStages = []tuneStage{ + {"placement", tunePlacementCands}, + {"marges", tuneFitTargetCands}, + {"lots", tuneBatchCands}, + {"délestage", tuneOffloadCands}, + {"threads", tuneThreadCands}, + {"files CUDA", tuneQueueCands}, + {"opt-in", tuneOptInCands}, +} + +// tuneStageWanted : l'étape fait-elle partie de celles demandées ? only vide = +// toutes. Placement et opt-in ne se filtrent pas ici : ils ont leur case, et +// leurs générateurs refusent sans elle. Noms comparés sans accent ni casse, par +// leur premier mot (« files CUDA » = « files », « délestage » = « delestage »). +func tuneStageWanted(name string, only []string) bool { + norm := func(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.NewReplacer("é", "e", "è", "e", "ê", "e").Replace(s) + if i := strings.IndexAny(s, " -"); i > 0 && s != "opt-in" { + s = s[:i] + } + return s + } + n := norm(name) + if len(only) == 0 || n == "placement" || n == "opt-in" { + return true + } + for _, o := range only { + if norm(o) == n { + return true + } + } + return false +} + +func tuneSI(env tuneEnv) serveSysInfo { + a := env.ArgEnv + if a == nil { + a = map[string]string{} + } + return serveSysInfo{Help: env.Help, ArgEnv: a, UserEnv: env.UserEnv} +} + +// tuneWeightsOnCPU : des poids resteront-ils en RAM ? Placés à la main, ou un +// modèle plus gros que la VRAM (--fit en laisse alors une part sur CPU). +func tuneWeightsOnCPU(cfg map[string]string, env tuneEnv) bool { + if cpuWeights(cfg, splitArgs(cfg["EXTRA_ARGS"]), env.ArgEnv, 0) != "" { + return true + } + return env.VRAMMiB > 0 && env.ModelBytes > env.VRAMMiB<<20 +} + +// tunePlacementCands : le placement manuel (experts au CPU, --tensor-split) +// remplacé par --fit. Seulement si l'utilisateur l'a coché : c'est une +// réécriture d'EXTRA_ARGS, montrée et confirmée à part à l'application. +func tunePlacementCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) { + if !env.Placement { + return nil, "non demandé (« inclure le placement »)" + } + extra := splitArgs(cfg["EXTRA_ARGS"]) + if !helpSupportsFit(env.Help) { + return nil, "ce moteur ne connaît pas --fit" + } + if c, ok := ctxFixed(cfg, extra); !ok { + return nil, "contexte « " + c + " » non chiffré : --fit pourrait le réduire" + } + if splitModeKey(cfg) == "tensor" { + return nil, "SPLIT_MODE=tensor" + } + moe := cpuExperts(extra, env.ArgEnv) != "" + ts := hasAnyFlag(extra, "-ts", "--tensor-split") && env.GPUs >= 2 + if !moe && !ts { + return nil, "rien n'est placé à la main" + } + e := cfg["EXTRA_ARGS"] + label := "placement auto (--fit)" + if moe { + e, _ = tuneExtraDropExperts(e) + label += " au lieu des experts placés à la main" + } + if ts { + e = tuneExtraDrop(e, 1, "-ts", "--tensor-split") + label += " au lieu de --tensor-split" + } + nc := setKey(cfg, "EXTRA_ARGS", e) + if why := fitBlocker(nc, splitArgs(e), tuneSI(env)); why != "" { + return nil, "--fit resterait inactif (" + why + ")" + } + return []tuneCand{{Stage: "placement", Label: label, Cfg: nc}}, "" +} + +// tuneFitTargetCands : la marge que --fit laisse libre par carte. Sur deux +// cartes inégales, plus de marge sur l'une pousse des couches vers l'autre. +func tuneFitTargetCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) { + extra := splitArgs(cfg["EXTRA_ARGS"]) + switch { + case env.GPUs < 2: + return nil, "une seule carte" + case !env.SMI: + return nil, "VRAM libre non mesurable (nvidia-smi)" + case !strings.Contains(env.Help, "--fit-target"): + return nil, "ce moteur ne connaît pas --fit-target" + } + if _, ok := ctxFixed(cfg, extra); !ok { + return nil, "contexte non chiffré" + } + if why := fitBlocker(cfg, extra, tuneSI(env)); why != "" { + return nil, "--fit inactif (" + why + ")" + } + vals := []string{"1536", "2048"} + if env.GPUs == 2 { + vals = []string{"1024,2048", "2048,1024", "1536"} + } + cur := knobFitTarget.get(cfg) + var out []tuneCand + for _, v := range vals { + if v == cur { + continue + } + out = append(out, tuneCand{Stage: "marges", Label: "FIT_TARGET=" + v, Cfg: knobFitTarget.set(cfg, v)}) + } + return out, "" +} + +// tuneBatchCands : micro-lot et lot. Un micro-lot plus grand prend plus de VRAM +// de calcul : sans mesure de la VRAM libre, on ne monte pas. Sur deux cartes +// tout GPU, le lot reste plus grand que le micro-lot, sans quoi le pipeline +// entre cartes n'a rien à se passer. +func tuneBatchCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) { + extra := splitArgs(cfg["EXTRA_ARGS"]) + curUB := serveUBatch(cfg, extra) + curB, _ := strconv.Atoi(firstNonEmpty(knobBatch.get(cfg), "2048")) + ubs := []int{512, 1024, 2048} + if env.MoE { + ubs = append(ubs, 4096) + } + multi := env.GPUs >= 2 && !tuneWeightsOnCPU(cfg, env) + var out []tuneCand + for _, ub := range ubs { + if !env.SMI && ub > curUB { + continue + } + seen := map[int]bool{} + for _, b := range []int{max(2048, ub), min(2*ub, 8192)} { + if seen[b] || b < ub || (multi && b <= ub) || (ub == curUB && b == curB) { + continue + } + seen[b] = true + c := knobUBatch.set(cfg, strconv.Itoa(ub)) + c = knobBatch.set(c, strconv.Itoa(b)) + out = append(out, tuneCand{Stage: "lots", Label: fmt.Sprintf("UBATCH=%d BATCH=%d", ub, b), Cfg: c}) + } + } + if len(out) == 0 { + return nil, "rien à essayer" + } + return out, "" +} + +// tuneOffloadCands : seuil d'envoi au GPU des poids restés en RAM. N'a de sens +// qu'avec des poids sur CPU. +func tuneOffloadCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) { + if !tuneWeightsOnCPU(cfg, env) { + return nil, "tous les poids sur GPU" + } + if env.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" { + return nil, "GGML_OP_OFFLOAD_MIN_BATCH déjà posé dans l'environnement" + } + cur := firstNonEmpty(strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"]), "32") + var out []tuneCand + for _, v := range []string{"32", "128", "512"} { + if v == cur { + continue + } + out = append(out, tuneCand{Stage: "délestage", Label: "OP_OFFLOAD_MIN_BATCH=" + v, + Cfg: setKey(cfg, "OP_OFFLOAD_MIN_BATCH", v)}) + } + return out, "" +} + +// tuneThreadCands : threads CPU, seulement quand des poids tournent sur CPU. +// « Omis » = aucun -t : llama.cpp prend ses cœurs physiques — pas THREADS=0, +// qui voulait dire « tous les threads logiques » avant le lot 1. +func tuneThreadCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) { + if !tuneWeightsOnCPU(cfg, env) { + return nil, "tous les poids sur GPU" + } + if env.PhysCores <= 1 { + return nil, "nombre de cœurs physiques inconnu" + } + cur := knobThreads.get(cfg) + if cur == "0" { + cur = "" + } + var out []tuneCand + seen := map[string]bool{cur: true} + for _, n := range []int{0, env.PhysCores - 1, env.PhysCores / 2} { + v := "" + if n > 0 { + v = strconv.Itoa(n) + } + if seen[v] || n < 0 { + continue + } + seen[v] = true + label := "THREADS=" + v + if v == "" { + label = "threads omis (cœurs physiques)" + } + out = append(out, tuneCand{Stage: "threads", Label: label, Cfg: knobThreads.set(cfg, v)}) + } + return out, "" +} + +// tuneQueueCands : files de lancement CUDA, sur deux cartes ou plus. +func tuneQueueCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) { + if env.GPUs < 2 { + return nil, "une seule carte" + } + if env.QueuesEnv { + return nil, "CUDA_SCALE_LAUNCH_QUEUES déjà posé dans l'environnement" + } + cur := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"])) + var out []tuneCand + for _, v := range []string{"off", "4x"} { + if v == cur { + continue + } + out = append(out, tuneCand{Stage: "files CUDA", Label: "CUDA_LAUNCH_QUEUES=" + v, + Cfg: setKey(cfg, "CUDA_LAUNCH_QUEUES", v)}) + } + return out, "" +} + +// tuneOptInCands : décodage spéculatif, graphes CUDA, échantillonnage sur GPU — +// seulement si l'utilisateur l'a coché. La spéculation reste exacte (chaque +// jeton vérifié par le modèle) ; elle est notée sur la prose ET sur le code. +func tuneOptInCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) { + if !env.OptIn { + return nil, "non demandé (« inclure les options opt-in »)" + } + extra := splitArgs(cfg["EXTRA_ARGS"]) + var out []tuneCand + mode := specMode(cfg) + if mode == "off" && helpSupportsMTP(env.Help) && !specUserSet(extra, env.ArgEnv) { + out = append(out, tuneCand{Stage: "opt-in", Label: "SPEC=auto", Cfg: setKey(cfg, "SPEC", "auto")}) + } + if mode != "off" && mode != "" && mode != "ngram" && strings.Contains(env.Help, "--spec-draft-n-max") { + cur := firstNonEmpty(knobSpecN.get(cfg), "3") + for _, v := range []string{"2", "3", "4"} { + if v != cur { + out = append(out, tuneCand{Stage: "opt-in", Label: "SPEC_N_MAX=" + v, Cfg: knobSpecN.set(cfg, v)}) + } + } + } + if !strings.EqualFold(strings.TrimSpace(cfg["CUDA_GRAPH_OPT"]), "on") && env.UserEnv["GGML_CUDA_GRAPH_OPT"] == "" { + out = append(out, tuneCand{Stage: "opt-in", Label: "CUDA_GRAPH_OPT=on", Cfg: setKey(cfg, "CUDA_GRAPH_OPT", "on")}) + } + if strings.Contains(env.Help, "--backend-sampling") && !hasAnyFlag(extra, "-bs", "--backend-sampling") && + !envTruthy(env.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"]) && splitModeKey(cfg) != "tensor" { + out = append(out, tuneCand{Stage: "opt-in", Label: "--backend-sampling", + Cfg: setKey(cfg, "EXTRA_ARGS", tuneExtraAddSwitch(cfg["EXTRA_ARGS"], "--backend-sampling"))}) + } + if len(out) == 0 { + return nil, "rien à essayer" + } + return out, "" +} + +// --- garde-fous ---------------------------------------------------------------- + +// tuneCoreArgs : la ligne de commande sans ce que l'optimiseur a le droit de +// régler. Deux lignes au même cœur ne diffèrent que par l'ordonnancement. +// Adresse, port, clé d'API, dossier des slots et taille auto du cache RAM (lue +// sur la RAM libre du moment) sont neutralisés : propres à l'essai ou au +// moment, sans effet sur le calcul. En opt-in, la spéculation entière +// (--spec-*, -md) sort aussi du cœur. +func tuneCoreArgs(args []string, optIn bool) []string { + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + a := args[i] + name, val, hasEq := strings.Cut(a, "=") + next := func() string { + if hasEq { + return val + } + if i+1 < len(args) { + i++ + return args[i] + } + return "" + } + switch name { + case "--host", "--port", "--api-key", "--slot-save-path", "-cram", "--cache-ram": + next() + out = append(out, name, "*") + continue + } + if optIn && (strings.HasPrefix(name, "--spec-") || name == "-md" || name == "--model-draft") { + next() + continue + } + f := tuneFlagFor(name, optIn) + if f == nil { + out = append(out, a) + continue + } + if f.arity == 0 { + continue + } + v := next() + if name == "-ot" || name == "--override-tensor" { + // Seuls les éléments qui visent les experts sont réglables. + var rest []string + for _, el := range strings.Split(v, ",") { + pat, _, _ := strings.Cut(strings.TrimSpace(el), "=") + if strings.TrimSpace(el) != "" && !otTargetsExperts(pat) { + rest = append(rest, strings.TrimSpace(el)) + } + } + if len(rest) > 0 { + out = append(out, name, strings.Join(rest, ",")) + } + } + } + return out +} + +// tuneEnvWhitelist : variables du moteur que l'optimiseur règle. +func tuneEnvTuned(k string, optIn bool) bool { + switch k { + case "CUDA_SCALE_LAUNCH_QUEUES", "GGML_OP_OFFLOAD_MIN_BATCH": + return true + case "GGML_CUDA_GRAPH_OPT": + return optIn + } + return false +} + +// tuneDenature compare la ligne d'un essai à celle de référence, hors liste +// blanche. "" = même calcul ; sinon, la première différence, en clair. +func tuneDenature(base, cand tuneLaunch, optIn bool) string { + ba, ca := tuneCoreArgs(base.Args, optIn), tuneCoreArgs(cand.Args, optIn) + for i := 0; i < max(len(ba), len(ca)); i++ { + var x, y string + if i < len(ba) { + x = ba[i] + } + if i < len(ca) { + y = ca[i] + } + if x != y { + return fmt.Sprintf("ligne de commande différente hors réglages permis (« %s » au lieu de « %s »)", y, x) + } + } + keys := map[string]bool{} + for k := range base.Env { + keys[k] = true + } + for k := range cand.Env { + keys[k] = true + } + for k := range keys { + if !tuneEnvTuned(k, optIn) && base.Env[k] != cand.Env[k] { + return fmt.Sprintf("variable %s différente (« %s » au lieu de « %s »)", k, cand.Env[k], base.Env[k]) + } + } + return "" +} + +// tuneLaunchKey : identité d'une ligne de lancement, adresse et port mis à +// part. Deux essais à la même clé lanceraient exactement le même moteur : le +// second n'est pas mesuré. +func tuneLaunchKey(l tuneLaunch) string { + full := make([]string, 0, len(l.Args)) + for i := 0; i < len(l.Args); i++ { + switch l.Args[i] { + case "--host", "--port", "--api-key", "--slot-save-path": + i++ + continue + } + full = append(full, l.Args[i]) + } + keys := make([]string, 0, len(l.Env)) + for k, v := range l.Env { + keys = append(keys, k+"="+v) + } + sort.Strings(keys) + return strings.Join(full, "\x00") + "\x01" + strings.Join(keys, "\x00") +} + +// tuneCfgCheck : la configuration d'un essai ne diffère de la référence que +// par des clés permises, et son EXTRA_ARGS que par des drapeaux de la liste +// blanche — les autres jetons à l'identique, dans l'ordre. Dernier filet avant +// tout lancement. +func tuneCfgCheck(base, cand map[string]string, optIn bool) error { + keys := map[string]bool{} + for k := range base { + keys[k] = true + } + for k := range cand { + keys[k] = true + } + for k := range keys { + if base[k] == cand[k] { + continue + } + if k == "EXTRA_ARGS" { + a, b := tuneCoreArgs(splitArgs(base[k]), optIn), tuneCoreArgs(splitArgs(cand[k]), optIn) + if strings.Join(a, "\x00") != strings.Join(b, "\x00") { + return fmt.Errorf("EXTRA_ARGS modifié hors liste blanche") + } + continue + } + if !tuneKeys[k] && !(optIn && tuneOptInKeys[k]) { + return fmt.Errorf("clé %s intouchable", k) + } + } + return nil +} + +// tuneDiff : ce qui change d'une configuration à l'autre, clé par clé. +func tuneDiff(base, cand map[string]string) []string { + keys := map[string]bool{} + for k := range base { + keys[k] = true + } + for k := range cand { + keys[k] = true + } + var names []string + for k := range keys { + if base[k] != cand[k] { + names = append(names, k) + } + } + sort.Strings(names) + show := func(v string) string { + if v == "" { + return "(absent)" + } + return v + } + var out []string + for _, k := range names { + out = append(out, k+" : "+show(base[k])+" → "+show(cand[k])) + } + return out +} + +// tunePatchPreset réécrit le preset clé par clé (presetSetKey) : seules les +// clés réglées bougent, le reste du fichier — commentaires, ordre, NAME — +// ne change pas. Vérifié : relu, le preset est l'ancien plus les clés réglées, +// rien d'autre. +func tunePatchPreset(content string, base, win map[string]string) (string, error) { + keys := map[string]bool{} + for k := range base { + keys[k] = true + } + for k := range win { + keys[k] = true + } + var changed []string + for k := range keys { + if base[k] != win[k] { + changed = append(changed, k) + } + } + sort.Strings(changed) + want := parseEnv(content) + out := content + for _, k := range changed { + if !tuneKeys[k] && !tuneOptInKeys[k] { + return "", fmt.Errorf("clé %s intouchable", k) + } + out = presetSetKey(out, k, win[k]) + if win[k] == "" { + delete(want, k) + } else { + want[k] = win[k] + } + } + got := parseEnv(out) + if len(got) != len(want) { + return "", fmt.Errorf("réécriture du preset incohérente") + } + for k, v := range want { + if got[k] != v { + return "", fmt.Errorf("réécriture du preset incohérente sur %s", k) + } + } + return out, nil +} + +// --- journal de l'essai ------------------------------------------------------------ + +var ( + reTuneOffloaded = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`) + reTuneKV = regexp.MustCompile(`K \(([A-Za-z0-9_]+)\):[^\n]*V \(([A-Za-z0-9_]+)\):`) +) + +// tuneLogFacts : couches déportées (dernière mention ; -1 = absente) et types +// du cache KV vus dans le journal (« f16/f16 », plusieurs caches dédoublonnés). +func tuneLogFacts(log string) (offloaded, total int, kv string) { + offloaded, total = -1, -1 + for _, m := range reTuneOffloaded.FindAllStringSubmatch(log, -1) { + offloaded, _ = strconv.Atoi(m[1]) + total, _ = strconv.Atoi(m[2]) + } + set := map[string]bool{} + for _, m := range reTuneKV.FindAllStringSubmatch(log, -1) { + set[strings.ToLower(m[1])+"/"+strings.ToLower(m[2])] = true + } + var kvs []string + for k := range set { + kvs = append(kvs, k) + } + sort.Strings(kvs) + return offloaded, total, strings.Join(kvs, ",") +} + +// tuneLoadFailure : pourquoi un essai n'a pas chargé, lu dans son journal. +func tuneLoadFailure(log string) string { + l := strings.ToLower(log) + switch { + case strings.Contains(l, "out of memory") || strings.Contains(l, "cudamalloc failed") || + strings.Contains(l, "failed to allocate"): + return "mémoire insuffisante (OOM)" + case strings.Contains(l, "failed to fit"): + return "--fit n'a pas su placer le modèle" + case strings.Contains(l, "failed to load model") || strings.Contains(l, "error loading model"): + return "échec du chargement du modèle" + } + lines := strings.Split(strings.TrimSpace(log), "\n") + if n := len(lines); n > 0 && lines[n-1] != "" { + last := strings.TrimSpace(lines[n-1]) + if len(last) > 160 { + last = last[:160] + } + return "moteur arrêté : " + last + } + return "moteur arrêté au chargement" +} + +// --- mesure et score ----------------------------------------------------------------- + +// tuneWeights : la forme d'un tour typique. K jetons neufs à lire (cache +// repris), G jetons à écrire, et une part fCold des tours qui relisent tout à +// froid à la profondeur D (compaction, changement de discussion). +type tuneWeights struct { + K float64 `json:"k"` + G float64 `json:"g"` + FCold float64 `json:"f_cold"` + From string `json:"from"` // « télémétrie » ou « défaut » +} + +var tuneDefaultWeights = tuneWeights{K: 2000, G: 600, FCold: 0.05, From: "défaut"} + +// tuneWeightsFrom tire K, G et fCold des médianes de la télémétrie (perf_log.go) +// quand elle en a assez vu ; sinon les valeurs par défaut. +func tuneWeightsFrom(recs []perfRec) tuneWeights { + var news, gens []float64 + cold, known := 0, 0 + for _, r := range recs { + if !r.Complete { + continue + } + switch r.Kind { + case perfBench, perfPrewarm, perfForeign: + continue + } + if r.New > 0 { + news = append(news, float64(r.New)) + } + if r.Gen > 0 { + gens = append(gens, float64(r.Gen)) + } + if r.Cached != nil && r.Total >= 2048 { + known++ + if *r.Cached*10 < r.Total { + cold++ + } + } + } + if len(news) < 20 || len(gens) < 20 { + return tuneDefaultWeights + } + w := tuneWeights{K: *perfMedian(news), G: *perfMedian(gens), FCold: tuneDefaultWeights.FCold, From: "télémétrie"} + w.K = min(max(w.K, 128), 32768) + w.G = min(max(w.G, 16), 8192) + if known >= 20 { + w.FCold = float64(cold) / float64(known) + } + return w +} + +// tuneRun : une mesure d'un essai (un passage du bench complet). +type tuneRun struct { + TurnSec float64 `json:"turn_sec"` // durée estimée d'un tour : le score, plus bas = mieux + ColdPP float64 `json:"cold_pp,omitempty"` // prefill à froid à D (t/s) + CachedPP float64 `json:"cached_pp,omitempty"` // jetons neufs par seconde des tours, reprise réelle comprise + TG float64 `json:"tg,omitempty"` // decode à D (code) + ProsePP float64 `json:"prose_pp"` + ProseTG float64 `json:"prose_tg"` // decode de la ligne courte (prose) + Depth int `json:"depth,omitempty"` +} + +// tuneRunFrom réduit un bench complet à son score. Le prefill des tours compte +// le temps RÉELLEMENT passé pour leurs jetons neufs : un hybride dont le +// micro-lot déplace les points de reprise relit plus de contexte, et le paie. +func tuneRunFrom(res *benchResult, w tuneWeights) (tuneRun, error) { + if res == nil || res.PredictedPerSec <= 0 || res.PromptPerSecond <= 0 { + return tuneRun{}, fmt.Errorf("mesure vide") + } + r := tuneRun{ProsePP: res.PromptPerSecond, ProseTG: res.PredictedPerSec} + d := res.Depth + if d == nil || d.Skipped != "" { + r.TurnSec = w.K/r.ProsePP + w.G/r.ProseTG + return r, nil + } + if d.Partial != "" { + return tuneRun{}, fmt.Errorf("mesure partielle : %s", d.Partial) + } + if d.Cold == nil || d.Cold.PromptPerSecond <= 0 || len(d.Turns) == 0 || d.DecodePerSec <= 0 { + return tuneRun{}, fmt.Errorf("mesure en profondeur incomplète") + } + var fresh int + var ms float64 + for _, t := range d.Turns { + if t.Cached < 0 || t.Expected <= 0 { + fresh, ms = 0, 0 + break + } + fresh += t.New + t.Cached - t.Expected + ms += t.PromptMs + } + r.CachedPP = d.CachedPerSec + if fresh > 0 && ms > 0 { + r.CachedPP = float64(fresh) / (ms / 1000) + } + if r.CachedPP <= 0 { + return tuneRun{}, fmt.Errorf("prefill des tours illisible") + } + r.ColdPP, r.TG, r.Depth = d.Cold.PromptPerSecond, d.DecodePerSec, d.Target + r.TurnSec = w.K/r.CachedPP + w.G/r.TG + w.FCold*float64(d.Target)/r.ColdPP + return r, nil +} + +func tuneMean(v []float64) float64 { + if len(v) == 0 { + return 0 + } + s := 0.0 + for _, x := range v { + s += x + } + return s / float64(len(v)) +} + +// tuneSpread : l'écart relatif entre passages ((max-min)/moyenne). +func tuneSpread(v []float64) float64 { + if len(v) < 2 { + return 0 + } + lo, hi := math.Inf(1), math.Inf(-1) + for _, x := range v { + lo, hi = min(lo, x), max(hi, x) + } + m := tuneMean(v) + if m <= 0 { + return 0 + } + return (hi - lo) / m +} + +// tuneMinGain : en dessous de 3 %, un gain se confond avec le bruit. +const tuneMinGain = 0.03 + +// tuneWins : le candidat (durées de tour, en secondes) bat-il la référence ? +// Seulement d'au moins max(3 %, l'écart entre passages de l'un ou de l'autre), +// et sur deux passages : un seul bon passage peut être un coup de chance. +func tuneWins(base, cand []float64) (gain, need float64, ok bool) { + mb := tuneMean(base) + if mb <= 0 || len(cand) == 0 { + return 0, tuneMinGain, false + } + gain = (mb - tuneMean(cand)) / mb + need = max(tuneMinGain, tuneSpread(base), tuneSpread(cand)) + return gain, need, len(cand) >= 2 && gain > need +} + +// tuneWorthRepeat : un premier passage assez prometteur pour en mériter un +// second (sinon on passe au suivant, le budget est compté). +func tuneWorthRepeat(base []float64, first float64) bool { + mb := tuneMean(base) + return mb > 0 && (mb-first)/mb > tuneMinGain +} + +// tuneTightMiB : marge VRAM minimale par carte, après le bench, pour qu'un +// essai puisse gagner. Ce que le bench n'exerce pas — encodage d'images du +// projecteur vision, autres conteneurs — vit dans cette marge. +const tuneTightMiB = 768 + +// tuneVisionReserveMiB : réserve de plus quand MMPROJ est posé (tampons de +// l'encodeur d'images, que le bench texte n'alloue pas). Estimation. +const tuneVisionReserveMiB = 512 + +// tuneHeadroom : VRAM libre minimale sur les cartes visibles (Mio). visible : +// index nvidia-smi des cartes du moteur ; vide = toutes. ok=false sans mesure. +func tuneHeadroom(gpus []gpuStat, visible []int) (minFree int, ok bool) { + minFree = math.MaxInt + for i, g := range gpus { + if len(visible) > 0 && !containsInt(visible, i) { + continue + } + if g.Total <= 0 { + continue + } + minFree = min(minFree, g.Total-g.Used) + ok = true + } + if !ok { + return 0, false + } + return minFree, true +} + +func containsInt(v []int, x int) bool { + for _, y := range v { + if y == x { + return true + } + } + return false +} + +// tuneVisibleGPUs : index des cartes de CUDA_VISIBLE_DEVICES (ordre PCI, celui +// de nvidia-smi). Vide = toutes, ou des UUID qu'on ne sait pas relier. +func tuneVisibleGPUs(cfg map[string]string) []int { + var out []int + for _, p := range strings.Split(cfg["CUDA_VISIBLE_DEVICES"], ",") { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return nil + } + out = append(out, n) + } + return out +} + +// --- fichiers de l'essai ------------------------------------------------------------- + +// tuneLaunch : la ligne de commande composée par « loki serve » pour un essai. +type tuneLaunch struct { + Bin string `json:"bin"` + Args []string `json:"args"` + Env map[string]string `json:"env,omitempty"` + Notes []string `json:"notes,omitempty"` + Probe *tuneProbe `json:"probe,omitempty"` +} + +// tuneProbe : ce que « loki serve » a sondé pour composer la ligne — dans le +// contexte exact du moteur (bibliothèques, cartes visibles, environnement). +// L'optimiseur s'en sert pour choisir ses essais. +type tuneProbe struct { + Help string `json:"help"` + GPUs int `json:"gpus"` + VRAMMiB int64 `json:"vram_mib"` + ModelBytes int64 `json:"model_bytes"` + MoE bool `json:"moe"` + CPUThreads int `json:"cpu_threads"` // sonde de conteneur (cpusetThreads) ; 0 = rien à dire + ArgEnv map[string]string `json:"arg_env,omitempty"` + UserEnv map[string]string `json:"user_env,omitempty"` + QueuesEnv bool `json:"queues_env"` +} + +// tuneProbeFrom : la sonde, tirée de ce que cmdServe a relevé. +func tuneProbeFrom(si serveSysInfo) *tuneProbe { + return &tuneProbe{Help: si.Help, GPUs: si.GPUs, VRAMMiB: si.VRAMMiB, ModelBytes: si.ModelBytes, + MoE: si.GGUF != nil && si.GGUF.ExpertCount > 0, CPUThreads: si.CPU.N, + ArgEnv: si.ArgEnv, UserEnv: si.UserEnv, QueuesEnv: si.LaunchQueues != ""} +} + +// writeTuneLaunch écrit la ligne de l'essai, clé d'API masquée. +func writeTuneLaunch(dir string, l tuneLaunch) error { + args := append([]string(nil), l.Args...) + for i := 0; i+1 < len(args); i++ { + if args[i] == "--api-key" { + args[i+1] = "***" + } + } + l.Args = args + b, err := json.MarshalIndent(l, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "launch.json"), b, 0o600) +} + +func readTuneLaunch(dir string) (tuneLaunch, error) { + var l tuneLaunch + b, err := os.ReadFile(filepath.Join(dir, "launch.json")) + if err != nil { + return l, err + } + return l, json.Unmarshal(b, &l) +} + +// writeTuneTrialConfig pose la configuration d'un essai dans son dossier — une +// copie, jamais config.env. Relue avant d'être confiée au moteur : une valeur +// que le format ne sait pas représenter serait un autre essai que celui voulu. +func writeTuneTrialConfig(dir string, cfg map[string]string) error { + text := formatEnv(cfg) + back := parseEnv(text) + if len(back) != len(cfg) { + return fmt.Errorf("configuration d'essai non représentable") + } + for k, v := range cfg { + if back[k] != v { + return fmt.Errorf("valeur de %s non représentable dans un fichier de configuration", k) + } + } + return os.WriteFile(filepath.Join(dir, "config.env"), []byte(text), 0o600) +} + +func readTuneTrialConfig(dir string) (map[string]string, error) { + b, err := os.ReadFile(filepath.Join(dir, "config.env")) + if err != nil { + return nil, fmt.Errorf("configuration d'essai illisible : %w", err) + } + return parseEnv(string(b)), nil +} + +// tuneForceAddr : toutes les occurrences de --host / --port pointent sur +// l'adresse de l'essai (EXTRA_ARGS pourrait en nommer d'autres, et la dernière +// gagne). +func tuneForceAddr(args []string, host, port string) []string { + out := append([]string(nil), args...) + for i := 0; i < len(out); i++ { + name, _, hasEq := strings.Cut(out[i], "=") + val := "" + switch name { + case "--host": + val = host + case "--port": + val = port + default: + continue + } + if hasEq { + out[i] = name + "=" + val + } else if i+1 < len(out) { + out[i+1] = val + i++ + } + } + return out +} diff --git a/internal/loki/backend_tune_job.go b/internal/loki/backend_tune_job.go new file mode 100644 index 0000000..632cfea --- /dev/null +++ b/internal/loki/backend_tune_job.go @@ -0,0 +1,378 @@ +package loki + +import ( + "bufio" + "context" + "encoding/json" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "time" +) + +// L'optimiseur côté web et en ligne de commande. +// +// POST /api/tune lance (202), 409 si le moteur est occupé +// GET /api/tune/status phase, essais, ETA ; puis le résultat +// POST /api/tune/cancel annule (l'essai en cours est arrêté, le moteur relancé) +// POST /api/tune/apply {target:"copy"|"preset"} — SEULEMENT sur un clic +// GET /api/tune/last dernier résultat d'un preset, et si le moteur a changé depuis +// +// Rien ne tourne de soi-même : ni au démarrage, ni après une mise à jour du +// moteur (l'interface PROPOSE alors de relancer). + +// tuneJob : l'optimisation en cours ou la dernière (une seule à la fois). +var tuneJob struct { + mu sync.Mutex + running bool + tracker *tuneTracker + cancel context.CancelFunc + result *tuneResult + err string + finished time.Time + // application en cours (preset réécrit, redémarrage, sonde) + applying bool + applyLog []string + applyMsg string + applyErr string +} + +// tuneRunner est runTune, remplaçable dans les tests. +var tuneRunner = runTune + +func tuneStart(opts tuneOpts) (int, error) { + if err := tunePreflight(context.Background(), true); err != nil { + code := http.StatusConflict + if externalActive() { + code = http.StatusBadRequest + } + return code, err + } + tuneJob.mu.Lock() + defer tuneJob.mu.Unlock() + if tuneJob.running || tuneJob.applying { + return http.StatusConflict, errTuneBusy + } + ctx, cancel := context.WithCancel(context.Background()) + // Le verrou de la discussion : un message, une tâche, une compaction + // reçoivent « optimisation en cours » au lieu d'attendre un moteur absent. + // Le bouton stop du chat annule l'optimisation, comme un bench. + release, err := conv.measureLease(cancel, errTuneBusy) + if err != nil { + cancel() + return http.StatusConflict, conv.busyReason() + } + tr := &tuneTracker{} + tuneJob.running, tuneJob.tracker, tuneJob.cancel = true, tr, cancel + tuneJob.result, tuneJob.err = nil, "" + tuneJob.applyLog, tuneJob.applyMsg, tuneJob.applyErr = nil, "", "" + opts.Via = "web" + go func() { + defer cancel() + res, err := tuneRunner(ctx, opts, tr) + tr.mu.Lock() + tr.ended = time.Now() + tr.mu.Unlock() + release() + tuneJob.mu.Lock() + defer tuneJob.mu.Unlock() + tuneJob.running, tuneJob.cancel, tuneJob.finished = false, nil, time.Now() + tuneJob.result = res + if err != nil { + tuneJob.err = err.Error() + if ctx.Err() != nil { + tuneJob.err = "optimisation annulée" + } + } + }() + return http.StatusAccepted, nil +} + +func tuneStatus() map[string]any { + tuneJob.mu.Lock() + defer tuneJob.mu.Unlock() + st := map[string]any{"ok": true, "running": tuneJob.running, "applying": tuneJob.applying} + if tuneJob.tracker != nil { + for k, v := range tuneJob.tracker.snapshot() { + st[k] = v + } + } + if !tuneJob.running { + if tuneJob.result != nil { + st["result"] = tuneJob.result + } + if tuneJob.err != "" { + st["error"] = tuneJob.err + } + } + if len(tuneJob.applyLog) > 0 || tuneJob.applyMsg != "" || tuneJob.applyErr != "" { + st["apply"] = map[string]any{"log": tuneJob.applyLog, "message": tuneJob.applyMsg, "error": tuneJob.applyErr} + } + // Une optimisation lancée ailleurs (« loki tune » en ligne de commande). + if o, busy := tuneActive(); busy && !tuneJob.running && !tuneJob.applying { + st["elsewhere"] = o.Via + } + return st +} + +func handleTune(w http.ResponseWriter, r *http.Request) { + if !postOnly(w, r) { + return + } + var body struct { + Placement bool `json:"placement"` + OptIn bool `json:"optin"` + BudgetMin int `json:"budget_min"` + Stages []string `json:"stages"` + } + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body) + opts := tuneOpts{Placement: body.Placement, OptIn: body.OptIn, Stages: body.Stages} + if body.BudgetMin > 0 { + opts.Budget = time.Duration(body.BudgetMin) * time.Minute + } + code, err := tuneStart(opts) + if err != nil { + sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, code, map[string]any{"ok": true}) +} + +func handleTuneStatus(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, tuneStatus()) } + +func handleTuneCancel(w http.ResponseWriter, r *http.Request) { + if !postOnly(w, r) { + return + } + tuneJob.mu.Lock() + ok := tuneJob.running && tuneJob.cancel != nil + if ok { + tuneJob.cancel() + } + tuneJob.mu.Unlock() + sendJSON(w, 200, map[string]any{"ok": ok}) +} + +// handleTuneApply écrit le dernier résultat, sur le clic de l'utilisateur +// (l'interface a montré le diff et demandé confirmation). Copie : immédiat. +// Preset : en arrière-plan (redémarrage, sonde, retour arrière si besoin). +func handleTuneApply(w http.ResponseWriter, r *http.Request) { + if !postOnly(w, r) { + return + } + var body struct { + Target string `json:"target"` + } + _ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body) + tuneJob.mu.Lock() + res := tuneJob.result + busy := tuneJob.running || tuneJob.applying + tuneJob.mu.Unlock() + if busy { + sendJSON(w, 409, map[string]any{"ok": false, "error": errTuneBusy.Error()}) + return + } + if res == nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun résultat à appliquer"}) + return + } + if body.Target == tuneApplyCopy { + msg, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {}) + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "message": msg}) + return + } + if body.Target != tuneApplyPreset { + sendJSON(w, 400, map[string]any{"ok": false, "error": "cible inconnue"}) + return + } + if _, _, err := tunePlanApply(res); err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + if conv.isGenerating() { + sendJSON(w, 409, map[string]any{"ok": false, "error": conv.busyReason().Error()}) + return + } + tuneJob.mu.Lock() + tuneJob.applying, tuneJob.applyLog, tuneJob.applyMsg, tuneJob.applyErr = true, nil, "", "" + tuneJob.mu.Unlock() + go func() { + say := func(s string) { + tuneJob.mu.Lock() + tuneJob.applyLog = append(tuneJob.applyLog, s) + tuneJob.mu.Unlock() + } + msg, err := tuneApply(context.Background(), res, tuneApplyPreset, say) + tuneJob.mu.Lock() + defer tuneJob.mu.Unlock() + tuneJob.applying = false + tuneJob.applyMsg = msg + if err != nil { + tuneJob.applyErr = err.Error() + } + }() + sendJSON(w, 202, map[string]any{"ok": true}) +} + +// handleTuneLast : le dernier résultat enregistré d'un preset, et si le moteur +// a changé depuis (build différent) — l'interface propose alors de relancer. +func handleTuneLast(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + res, ok := loadTuneResult(id) + if !ok { + sendJSON(w, 200, map[string]any{"ok": false}) + return + } + out := map[string]any{"ok": true, "result": res} + if content, err := ReadPreset(id); err == nil { + out["preset_changed"] = presetFingerprint([]byte(content)) != res.PresetFP + } + if bin := tuneEngineBin(ReadConfig()); bin != "" && res.EngineBuild > 0 { + if b := engineBuildCached(bin); b > 0 && b != res.EngineBuild { + out["engine_changed"] = fmt.Sprintf("b%d → b%d", res.EngineBuild, b) + } + } + sendJSON(w, 200, out) +} + +// tuneEngineBin : le binaire du moteur, résolu comme cmdServe le fait. +func tuneEngineBin(cfg map[string]string) string { + bin := strings.TrimSpace(cfg["BIN"]) + if bin == "" { + return "" + } + if !filepath.IsAbs(bin) { + bin = filepath.Join(LokiHome(), bin) + } + return prebuiltResolveBin(bin) +} + +// --- ligne de commande ------------------------------------------------------------- + +// cmdTune : « loki tune [--placement] [--opt-in] [--budget MIN] [--stages …] ». Mesure, montre, +// puis DEMANDE où écrire le résultat — rien n'est écrit sans réponse. Comme +// « loki bench », ce processus ne voit pas la discussion du processus web : +// seule la vérification /slots (moteur occupé) protège un tour en cours ; une +// fois le verrou pris, le processus web refuse tout nouveau tour. Le bouton de +// l'interface, lui, voit tout. +func cmdTune(args []string) error { + fs := flag.NewFlagSet("tune", flag.ContinueOnError) + placement := fs.Bool("placement", false, "essayer --fit à la place du placement manuel (réécrit EXTRA_ARGS)") + optIn := fs.Bool("opt-in", false, "essayer aussi SPEC, CUDA_GRAPH_OPT et --backend-sampling") + budget := fs.Int("budget", int(tuneDefaultBudget/time.Minute), "budget en minutes") + stages := fs.String("stages", "", "étapes de base, séparées par des virgules : marges,lots,delestage,threads,files (vide = toutes)") + if err := fs.Parse(args); err != nil { + return err + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := tunePreflight(ctx, false); err != nil { + return err + } + fmt.Printf("\n %s — essais sur un moteur privé (127.0.0.1), config.env jamais modifié.\n", cyan("Optimiseur sans perte")) + fmt.Println(dim(" Le chat et les tâches attendent pendant la mesure ; Ctrl-C annule (le moteur est relancé).")) + fmt.Println() + // Ctrl-C annule proprement : l'essai est arrêté, le vrai moteur relancé. + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + defer signal.Stop(sig) + go func() { + if _, ok := <-sig; ok { + cancel() + } + }() + tr := &tuneTracker{print: true} + var only []string + for _, s := range strings.Split(*stages, ",") { + if s = strings.TrimSpace(s); s != "" { + only = append(only, s) + } + } + res, err := runTune(ctx, tuneOpts{Placement: *placement, OptIn: *optIn, + Budget: time.Duration(*budget) * time.Minute, Via: "cli", Stages: only}, tr) + if err != nil { + return err + } + printTuneResult(res) + if len(res.Set) == 0 { + return nil + } + in := bufio.NewScanner(os.Stdin) + ask := func(q string) string { + fmt.Print(q) + if !in.Scan() { + return "" + } + return strings.ToLower(strings.TrimSpace(in.Text())) + } + switch ask(" Enregistrer ? [c] dans une copie du preset · [p] dans « " + res.PresetName + " » (redémarre le moteur) · [N] non : ") { + case "c": + msg, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {}) + if err != nil { + return err + } + fmt.Println(green("[ok] ") + msg) + case "p": + if res.Placement && ask(" Le placement réécrit EXTRA_ARGS (voir ci-dessus). Confirmer ? [o/N] ") != "o" { + fmt.Println(dim(" rien n'a été écrit")) + return nil + } + msg, err := tuneApply(context.Background(), res, tuneApplyPreset, func(s string) { fmt.Println(" " + s) }) + if err != nil { + return err + } + fmt.Println(green("[ok] ") + msg) + default: + fmt.Println(dim(" rien n'a été écrit (le résultat reste visible dans l'éditeur du preset)")) + } + return nil +} + +func printTuneResult(res *tuneResult) { + fmt.Println() + row := func(t tuneTrial) { + turn := "—" + if t.TurnSec > 0 { + turn = fmt.Sprintf("%.1f s", t.TurnSec) + } + why := t.Why + if t.Headroom >= 0 { + why += fmt.Sprintf(" · %d Mio libres", t.Headroom) + } + fmt.Printf(" %-10s %-44s %8s %-8s %s\n", t.Stage, t.Label, turn, t.Status, strings.TrimPrefix(why, " · ")) + } + fmt.Printf(" %s (K=%.0f jetons lus, G=%.0f écrits, %.0f %% à froid à %d jetons — %s)\n", + cyan("Durée d'un tour type"), res.Weights.K, res.Weights.G, res.Weights.FCold*100, res.Depth, res.Weights.From) + row(res.Baseline) + for _, t := range res.Trials { + row(t) + } + for _, s := range res.Skipped { + fmt.Println(dim(" sauté — " + s)) + } + for _, n := range res.Notes { + fmt.Println(yellow(" ! ") + n) + } + if res.Partial != "" { + fmt.Println(yellow(" ! résultat partiel : ") + res.Partial) + } + fmt.Println() + if len(res.Set) == 0 { + fmt.Println(" La configuration actuelle reste la meilleure : rien à changer.") + return + } + fmt.Printf(" %s %s : −%.1f %% par tour\n", green("Meilleur :"), res.Best, res.Gain*100) + for _, c := range res.Changes { + fmt.Println(" " + c) + } + fmt.Println() +} diff --git a/internal/loki/backend_tune_lock.go b/internal/loki/backend_tune_lock.go new file mode 100644 index 0000000..a7b8506 --- /dev/null +++ b/internal/loki/backend_tune_lock.go @@ -0,0 +1,274 @@ +package loki + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// Verrou de l'optimiseur (loki tune, bouton « Optimiser »). +// +// Pendant une optimisation, la VRAM appartient au moteur d'ESSAI : le vrai +// moteur est arrêté, et le relancer à côté (bascule de preset, enregistrement du +// preset actif, choix des GPU, mise à jour du moteur, « recharger » la VRAM…) +// chargerait deux modèles sur des cartes qui n'en tiennent qu'un — un OOM certain, +// et une mesure faussée. Un drapeau en mémoire ne suffit pas : « loki tune » en +// ligne de commande et le processus web sont deux processus distincts. Le verrou +// est donc un FICHIER de LOKI_HOME, créé de façon exclusive, que tout le monde +// lit : +// +// - serviceAction("start"/"restart") le consulte sur toutes les plateformes — +// aucun démarrage du moteur ne passe à côté ; +// - le chat, les tâches planifiées (qui attendent), les jobs du mode code, les +// clients /v1, le bench, la bascule et l'enregistrement de preset refusent +// avec une phrase claire. +// +// 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 (Loki redémarré en plein essai) est périmé : avant tout +// démarrage du moteur, l'essai orphelin est arrêté — seulement si PID, instant +// de démarrage ET binaire concordent, jamais sur la foi d'un PID recyclé — puis +// le fichier est retiré. + +const tuneLockName = "tune.lock" + +var errTuneBusy = errors.New("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é. +type tuneProc struct { + PID int `json:"pid"` + Start string `json:"start,omitempty"` // procIdentity ; vide = illisible au moment de l'écrire + Exes []string `json:"exes,omitempty"` // binaires admis (un « loki serve » devient llama-server par exec) +} + +// tuneOwner : le contenu du verrou. +type tuneOwner struct { + Owner tuneProc `json:"owner"` + Via string `json:"via"` // « web » ou « cli » + Phase string `json:"phase"` // « essais » ou « application » + Since int64 `json:"since"` + Trial *tuneProc `json:"trial,omitempty"` + // MainWasActive : le vrai moteur tournait avant l'optimisation. Un verrou + // périmé trouvé au démarrage du processus web le relance. + MainWasActive bool `json:"main_was_active"` +} + +func tuneLockPath() string { return filepath.Join(LokiHome(), tuneLockName) } + +// tuneProcAlive dit si p désigne encore le MÊME processus. Variable pour les +// tests. +var tuneProcAlive = func(p tuneProc) bool { + start, exe, ok := procIdentity(p.PID) + if !ok { + return false + } + if p.Start != "" && start != p.Start { + return false + } + if exe == "" || len(p.Exes) == 0 { + return true + } + for _, e := range p.Exes { + if sameExe(e, exe) { + return true + } + } + return false +} + +// sameExe compare deux binaires par leur nom de fichier (sans .exe, sans +// casse) : /proc//exe résout les liens symboliques, ps n'en donne parfois +// que le nom. +func sameExe(a, b string) bool { + norm := func(s string) string { + s = strings.ToLower(filepath.Base(strings.ReplaceAll(strings.TrimSpace(s), `\`, "/"))) + return strings.TrimSuffix(s, ".exe") + } + return a != "" && b != "" && norm(a) == norm(b) +} + +// selfProc : ce processus, tel que le verrou le note. +func selfProc() tuneProc { + p := tuneProc{PID: os.Getpid()} + if start, exe, ok := procIdentity(p.PID); ok { + p.Start = start + if exe != "" { + p.Exes = []string{exe} + } + } + if self, err := os.Executable(); err == nil && len(p.Exes) == 0 { + p.Exes = []string{self} + } + return p +} + +func readTuneOwner(path string) (tuneOwner, bool) { + var o tuneOwner + b, err := os.ReadFile(path) + if err != nil || json.Unmarshal(b, &o) != nil || o.Owner.PID <= 0 { + return tuneOwner{}, false + } + return o, true +} + +// tuneActive : une optimisation tient-elle le moteur, dans ce processus ou dans +// un autre ? Un verrou périmé (propriétaire mort) ne compte pas. +func tuneActive() (tuneOwner, bool) { + o, ok := readTuneOwner(tuneLockPath()) + if !ok { + return tuneOwner{}, false + } + return o, tuneProcAlive(o.Owner) +} + +// tuneGuard : nil si rien ne tient le moteur, errTuneBusy sinon. +func tuneGuard() error { + if _, busy := tuneActive(); busy { + return errTuneBusy + } + return nil +} + +// tuneDenyHTTP répond 409 et rend true quand une optimisation tient le moteur. +func tuneDenyHTTP(w http.ResponseWriter) bool { + if err := tuneGuard(); err != nil { + sendJSON(w, http.StatusConflict, map[string]any{"ok": false, "tuning": true, "error": err.Error()}) + return true + } + return false +} + +// tuneLock : le verrou tenu par CE processus. +type tuneLock struct { + mu sync.Mutex + path string + owner tuneOwner +} + +// tuneLockAcquire prend le verrou, ou dit qui le tient. Un verrou périmé est +// d'abord écarté (tuneReapStale), essai orphelin compris. +func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) { + path := tuneLockPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + l := &tuneLock{path: path, owner: tuneOwner{Owner: selfProc(), Via: via, Phase: "essais", + Since: time.Now().Unix(), MainWasActive: mainActive}} + for attempt := 0; attempt < 3; attempt++ { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + b, _ := json.Marshal(l.owner) + _, werr := f.Write(b) + cerr := f.Close() + if werr != nil || cerr != nil { + _ = os.Remove(path) + return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr)) + } + return l, nil + } + if !os.IsExist(err) { + return nil, err + } + if o, busy := tuneActive(); busy { + return nil, fmt.Errorf("une optimisation tourne déjà (%s, PID %d, depuis %s)", o.Via, o.Owner.PID, + time.Unix(o.Since, 0).Format("15:04")) + } + tuneReapStale() + } + return nil, errors.New("verrou de l'optimiseur indisponible : réessaie") +} + +// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un +// lecteur ne voit jamais un JSON à moitié écrit. +func (l *tuneLock) write() error { + b, err := json.Marshal(l.owner) + if err != nil { + return err + } + tmp := l.path + ".tmp-" + strconv.Itoa(os.Getpid()) + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, l.path) +} + +// setTrial note l'essai en cours (nil : aucun), AVANT qu'il ne charge quoi que +// ce soit d'important : c'est ce qui permet à un Loki redémarré de l'arrêter. +func (l *tuneLock) setTrial(p *tuneProc) { + l.mu.Lock() + defer l.mu.Unlock() + l.owner.Trial = p + _ = l.write() +} + +func (l *tuneLock) setPhase(phase string) { + l.mu.Lock() + defer l.mu.Unlock() + l.owner.Phase = phase + _ = l.write() +} + +// release rend le verrou — seulement s'il est encore le nôtre. +func (l *tuneLock) release() { + l.mu.Lock() + defer l.mu.Unlock() + if o, ok := readTuneOwner(l.path); ok && o.Owner.PID == l.owner.Owner.PID && o.Owner.Start == l.owner.Owner.Start { + _ = os.Remove(l.path) + } +} + +// tuneReapStale écarte un verrou dont le propriétaire est mort : l'essai qu'il +// désigne est arrêté s'il est encore là (identité complète vérifiée), puis le +// fichier disparaît. Rend MainWasActive du verrou écarté (false sans verrou +// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou +// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps. +func tuneReapStale() (mainWasActive bool) { + path := tuneLockPath() + o, ok := readTuneOwner(path) + if !ok { + // Illisible (écriture interrompue) : périmé lui aussi, s'il est là. + if _, err := os.Stat(path); err == nil { + _ = os.Remove(path) + } + return false + } + if tuneProcAlive(o.Owner) { + return false + } + stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano()) + if os.Rename(path, stale) != nil { + return false + } + defer os.Remove(stale) + if o.Trial != nil && tuneProcAlive(*o.Trial) { + fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID) + tuneKillTree(o.Trial.PID, nil) + } + return o.MainWasActive +} + +// tuneRecoverAtBoot : au démarrage du processus web, une optimisation +// interrompue (Loki tué en plein essai) a laissé le vrai moteur arrêté et peut- +// être un essai en VRAM. On arrête l'essai, puis on relance le moteur s'il +// tournait avant — ce que l'optimisation aurait fait en finissant. +func tuneRecoverAtBoot() { + if _, err := os.Stat(tuneLockPath()); err != nil { + return + } + if !tuneReapStale() || externalActive() || serviceIsActive() { + return + } + if err := preflightEngine(); err != nil { + return + } + fmt.Println(dim("[loki tune] optimisation interrompue : relance du moteur")) + _ = serviceActionOS("start") +} diff --git a/internal/loki/backend_tune_run.go b/internal/loki/backend_tune_run.go new file mode 100644 index 0000000..f6c26af --- /dev/null +++ b/internal/loki/backend_tune_run.go @@ -0,0 +1,1002 @@ +package loki + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// Optimiseur sans perte — le déroulé (la partie pure est dans backend_tune.go). +// +// 1. Refus d'entrée : preset externe, aucun preset actif, génération, tâche, +// bench ou job en cours, moteur occupé par un client. +// 2. Verrou exclusif (backend_tune_lock.go), vrai moteur arrêté comme le fait +// « décharger la VRAM ». +// 3. Chaque essai : une COPIE de la configuration avec ses surcharges, posée +// dans LOKI_HOME/tune/run//config.env — jamais config.env — puis +// « loki serve » à blanc (la ligne de commande, comparée à la référence +// avant tout chargement), puis pour de vrai sur 127.0.0.1 et un port libre. +// Mesure : le bench complet du lot 1 (llm_bench.go) à une profondeur fixe. +// Le groupe de processus de l'essai est tué en sortie, quoi qu'il arrive. +// 4. Fin : le vrai moteur est relancé (comme handleVramReload), le verrou +// rendu. Le résultat est montré ; RIEN n'est écrit dans un preset sans un +// geste de l'utilisateur (tuneApply). + +// tuneOpts : ce que l'utilisateur a demandé. +type tuneOpts struct { + Placement bool // « inclure le placement » (réécrit EXTRA_ARGS pour --fit) + OptIn bool // « inclure les options opt-in » (SPEC, CUDA_GRAPH_OPT, --backend-sampling) + Budget time.Duration // 0 = tuneDefaultBudget + Via string // « web » ou « cli » + // Stages : étapes de base à faire (marges, lots, délestage, threads, files) ; + // vide = toutes. Placement et opt-in ont leur propre case. + Stages []string +} + +const ( + tuneDefaultBudget = 30 * time.Minute + tuneMaxBudget = 6 * time.Hour +) + +// tuneLoadTimeout : chargement d'un essai. Un MoE de 80 Go relu depuis un +// disque lent prend plusieurs minutes ; au-delà, l'essai est déclaré en échec. +var tuneLoadTimeout = 20 * time.Minute + +// tuneTrial : un essai, tel que montré à l'utilisateur. +type tuneTrial struct { + Stage string `json:"stage"` + Label string `json:"label"` + Status string `json:"status"` // en cours, mesuré, retenu, écarté, échec, doublon + Why string `json:"why,omitempty"` + Runs []tuneRun `json:"runs,omitempty"` + TurnSec float64 `json:"turn_sec,omitempty"` // moyenne des passages + Gain float64 `json:"gain"` // contre la meilleure configuration du moment + Headroom int `json:"headroom_mib"` // VRAM libre minimale après le bench ; -1 = non mesurée + Offloaded string `json:"offloaded,omitempty"` + Changes []string `json:"changes,omitempty"` // contre la référence + Sec float64 `json:"sec"` +} + +// tuneResult : l'issue d'une optimisation. +type tuneResult struct { + PresetID string `json:"preset_id"` + PresetName string `json:"preset_name"` + BaseFP string `json:"base_fp"` // configFingerprint de la configuration mesurée + PresetFP string `json:"preset_fp"` // presetFingerprint du preset au départ + Engine string `json:"engine"` // build_info du moteur + EngineBuild int `json:"engine_build"` + Weights tuneWeights `json:"weights"` + Depth int `json:"depth"` + NCtx int `json:"n_ctx"` + Slots int `json:"slots"` + Baseline tuneTrial `json:"baseline"` + Trials []tuneTrial `json:"trials"` + Best string `json:"best,omitempty"` // libellé de l'essai retenu ; vide = la référence reste la meilleure + Gain float64 `json:"gain"` // contre la référence + Set map[string]string `json:"set,omitempty"` // clés changées et leur nouvelle valeur ("" = retirée) + Old map[string]string `json:"old,omitempty"` // leur valeur mesurée en référence + Changes []string `json:"changes,omitempty"` + Placement bool `json:"placement"` // EXTRA_ARGS réécrit : confirmation à part + Partial string `json:"partial,omitempty"` + Skipped []string `json:"skipped,omitempty"` + Notes []string `json:"notes,omitempty"` + OptIn bool `json:"opt_in"` + At int64 `json:"at"` +} + +// tuneTracker : la progression, lue par l'interface ou affichée par la CLI. +type tuneTracker struct { + mu sync.Mutex + phase string + started time.Time + ended time.Time // fin de la mesure ; zéro = en cours + trials []tuneTrial + avgSec float64 // durée moyenne d'un essai mesuré + left int // essais encore prévus (estimation) + budget time.Duration + print bool +} + +func (t *tuneTracker) setPhase(format string, a ...any) { + t.mu.Lock() + t.phase = fmt.Sprintf(format, a...) + p := t.print + t.mu.Unlock() + if p { + fmt.Printf(" %s %s\n", dim(time.Now().Format("15:04:05")), fmt.Sprintf(format, a...)) + } +} + +func (t *tuneTracker) put(i int, tr tuneTrial) int { + t.mu.Lock() + defer t.mu.Unlock() + if i < 0 || i >= len(t.trials) { + t.trials = append(t.trials, tr) + return len(t.trials) - 1 + } + t.trials[i] = tr + return i +} + +func (t *tuneTracker) snapshot() map[string]any { + t.mu.Lock() + defer t.mu.Unlock() + end := t.ended + if end.IsZero() { + end = time.Now() + } + el := end.Sub(t.started) + eta := -1.0 + if t.avgSec > 0 { + eta = min(t.avgSec*float64(t.left), (t.budget - el).Seconds()) + eta = max(eta, 0) + } + return map[string]any{"phase": t.phase, "elapsed_sec": el.Seconds(), "eta_sec": eta, + "trials": append([]tuneTrial(nil), t.trials...)} +} + +// tuneActivePreset : le preset dont l'empreinte est celle de la configuration +// active — c'est lui que l'optimisation mesure, et lui seul qu'elle peut +// réécrire. Aucun : la mesure n'aurait nulle part où aller. +func tuneActivePreset() (Preset, string, error) { + list, err := ListPresets() + if err != nil { + return Preset{}, "", err + } + for _, p := range list { + if p.Active { + content, err := ReadPreset(p.ID) + return p, content, err + } + } + return Preset{}, "", errors.New("aucun preset ne correspond à la configuration active : enregistre-la comme preset " + + "(ou bascule sur l'un d'eux) avant d'optimiser") +} + +// tuneAuth : l'en-tête du moteur protégé par clé (essai comme vrai moteur). +func tuneAuth() func(set func(k, v string)) { + key, _ := effectiveAPIKeyErr() + return func(set func(k, v string)) { + if key != "" { + set("Authorization", "Bearer "+key) + } + } +} + +// tunePreflight : tout ce qui interdit de commencer, dans CE processus. Le +// processus web voit la discussion, les tâches et les jobs ; la CLI ne voit que +// le moteur (/slots). +func tunePreflight(ctx context.Context, inWeb bool) error { + if externalActive() { + return errors.New("optimisation indisponible : le preset actif est une API externe") + } + if err := tuneGuard(); err != nil { + return err + } + if _, _, err := tuneActivePreset(); err != nil { + return err + } + if inWeb { + switch { + case benchRunning(): + return errBenchBusy + case conv.isGenerating(): + return conv.busyReason() + case bgJobsRunning() > 0: + return fmt.Errorf("%d job(s) d'arrière-plan du mode code tournent encore (bash_bg) : ils fausseraient les mesures — arrête-les d'abord", + bgJobsRunning()) + } + } + if serviceIsActive() { + e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient} + if err := e.idle(ctx); err != nil { + return errors.New("le moteur traite une requête (discussion, tâche ou client /v1) : réessaie une fois libre") + } + } + return nil +} + +// tunePhysCores : cœurs physiques (Linux ; ailleurs inconnu, l'axe threads est +// sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite. +func tunePhysCores(probe *tuneProbe) int { + if probe != nil && probe.CPUThreads > 0 { + return probe.CPUThreads + } + fsys := os.DirFS("/") + online, err := readCPUList(fsys, "sys/devices/system/cpu/online") + if err != nil || len(online) == 0 { + return 0 + } + atom, _ := readCPUList(fsys, "sys/devices/cpu_atom/cpus") + n, err := physicalCores(fsys, online, atom) + if err != nil { + return 0 + } + return n +} + +func tuneFreePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +// tuneTrialer lance les essais d'une optimisation. +type tuneTrialer struct { + dir string + self string + lock *tuneLock + seq int + optIn bool +} + +// prepare pose la configuration d'un essai (copie + adresse privée) dans un +// dossier neuf. +func (t *tuneTrialer) prepare(cfg map[string]string) (dir string, port int, err error) { + t.seq++ + dir = filepath.Join(t.dir, fmt.Sprintf("e%02d", t.seq)) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", 0, err + } + if port, err = tuneFreePort(); err != nil { + return "", 0, err + } + c := copyCfg(cfg) + c["HOST"], c["PORT"] = "127.0.0.1", strconv.Itoa(port) + return dir, port, writeTuneTrialConfig(dir, c) +} + +// dry : « loki serve » à blanc — la ligne de commande, rien de chargé. +func (t *tuneTrialer) dry(ctx context.Context, dir string) (tuneLaunch, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + cmd := hideCmd(exec.CommandContext(ctx, t.self, "serve")) + cmd.Dir = LokiHome() + cmd.Env = append(os.Environ(), tuneTrialEnv+"="+dir, tuneDryRunEnv+"=1") + out, err := cmd.CombinedOutput() + if err != nil { + msg := strings.TrimSpace(ansiRe.ReplaceAllString(string(out), "")) + if i := strings.LastIndex(msg, "\n"); i >= 0 { + msg = msg[i+1:] + } + if msg == "" { + msg = err.Error() + } + return tuneLaunch{}, errors.New(msg) + } + return readTuneLaunch(dir) +} + +// tuneLoaded : un essai chargé et prêt. +type tuneLoaded struct { + eng benchEngine + nCtx int + slots int + build string + offloaded int + layers int + kv string + log string +} + +// tuneProps lit n_ctx, total_slots et build_info du moteur. +func tuneProps(ctx context.Context, e benchEngine) (nCtx, slots int, build string, err error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + data, err := e.do(ctx, http.MethodGet, "/props", nil) + if err != nil { + return 0, 0, "", err + } + var p struct { + BuildInfo string `json:"build_info"` + TotalSlots int `json:"total_slots"` + NCtx int `json:"n_ctx"` + Default struct { + NCtx int `json:"n_ctx"` + } `json:"default_generation_settings"` + } + if err := json.Unmarshal(data, &p); err != nil { + return 0, 0, "", err + } + nCtx = p.Default.NCtx + if nCtx <= 0 { + nCtx = p.NCtx + } + return nCtx, p.TotalSlots, p.BuildInfo, nil +} + +// load lance l'essai pour de vrai et attend qu'il réponde. La fonction rendue +// l'arrête (groupe entier), efface sa trace du verrou et attend que la VRAM +// soit rendue : à appeler en defer, quoi qu'il arrive. +func (t *tuneTrialer) load(ctx context.Context, dir string, port int, launch tuneLaunch) (*tuneLoaded, func(), error) { + logPath := filepath.Join(dir, "engine.log") + logf, err := os.Create(logPath) + if err != nil { + return nil, func() {}, err + } + cmd := exec.Command(t.self, "serve") + cmd.Dir = LokiHome() + cmd.Env = append(os.Environ(), tuneTrialEnv+"="+dir) + cmd.Stdout, cmd.Stderr = logf, logf + tuneTrialAttr(cmd) + if err := cmd.Start(); err != nil { + logf.Close() + return nil, func() {}, err + } + pid := cmd.Process.Pid + done := make(chan struct{}) + go func() { + _ = cmd.Wait() + logf.Close() + close(done) + }() + proc := tuneProc{PID: pid, Exes: []string{t.self, launch.Bin}} + if start, _, ok := procIdentity(pid); ok { + proc.Start = start + } + t.lock.setTrial(&proc) + stop := func() { + before := gpuUsedMB(gpuStats()) + tuneKillTree(pid, done) + t.lock.setTrial(nil) + if before > 0 { + gpuUsedSettled(before) + } else { + time.Sleep(2 * time.Second) + } + } + readLog := func() string { + b, _ := os.ReadFile(logPath) + return string(b) + } + e := benchEngine{base: fmt.Sprintf("http://127.0.0.1:%d", port), auth: tuneAuth(), client: http.DefaultClient} + deadline := time.Now().Add(tuneLoadTimeout) + hc := &http.Client{Timeout: 3 * time.Second} + for { + select { + case <-ctx.Done(): + return nil, stop, ctx.Err() + case <-done: + return nil, stop, errors.New(tuneLoadFailure(readLog())) + case <-time.After(time.Second): + } + if resp, err := hc.Get(e.base + "/health"); err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + break + } + } + if time.Now().After(deadline) { + return nil, stop, fmt.Errorf("pas prêt après %s", tuneLoadTimeout) + } + } + nCtx, slots, build, err := tuneProps(ctx, e) + if err != nil { + return nil, stop, fmt.Errorf("/props illisible : %w", err) + } + log := readLog() + off, layers, kv := tuneLogFacts(log) + return &tuneLoaded{eng: e, nCtx: nCtx, slots: slots, build: build, offloaded: off, layers: layers, kv: kv, log: log}, stop, nil +} + +// tuneBenchSetup : le bench du preset de l'essai — son échantillonnage et son +// raisonnement réels (une spéculation se juge sur le tirage du preset, pas en +// glouton), profondeur plafonnée comme celle de la référence. +func tuneBenchSetup(cfg map[string]string, argEnv map[string]string, cpuPlaced bool) benchSetup { + s := benchSetupFrom(cfg, argEnv) + s.learn = func(want, got string) { effortRemember(want, got) } + s.cpuPlaced = cpuPlaced + return s +} + +// runTune déroule une optimisation. L'appelant fournit le suivi ; le verrou, +// l'arrêt et la relance du vrai moteur sont faits ici. +func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (*tuneResult, error) { + if opts.Budget <= 0 { + opts.Budget = tuneDefaultBudget + } + opts.Budget = min(opts.Budget, tuneMaxBudget) + tr.mu.Lock() + tr.started, tr.budget = time.Now(), opts.Budget + tr.mu.Unlock() + cfg := ReadConfig() + preset, content, err := tuneActivePreset() + if err != nil { + return nil, err + } + self, err := os.Executable() + if err != nil { + return nil, err + } + mainActive := engineNeedsStop() + lock, err := tuneLockAcquire(opts.Via, mainActive) + if err != nil { + return nil, err + } + defer lock.release() + // Le vrai moteur revient AVANT que le verrou ne soit rendu (les defer + // passent dans l'ordre inverse) : personne ne le relance par-dessus un essai. + defer func() { + if !mainActive { + return + } + lock.setPhase("relance") + tr.setPhase("relance du moteur principal") + if err := preflightEngine(); err != nil { + fmt.Fprintf(os.Stderr, "[loki tune] relance impossible : %s\n", plainErr(err)) + return + } + if err := serviceActionOS("start"); err != nil { + fmt.Fprintf(os.Stderr, "[loki tune] relance du moteur : %s\n", plainErr(err)) + } + gpuMonitor.invalidate() + }() + + runDir := filepath.Join(LokiHome(), "tune", "run") + _ = os.RemoveAll(runDir) + if err := os.MkdirAll(runDir, 0o700); err != nil { + return nil, err + } + t := &tuneTrialer{dir: runDir, self: self, lock: lock, optIn: opts.OptIn} + + if mainActive { + tr.setPhase("arrêt du moteur principal") + gpus := gpuStats() + before := gpuUsedMB(gpus) + if err := serviceActionOS("stop"); err != nil { + return nil, fmt.Errorf("arrêt du moteur : %s", plainErr(err)) + } + asrShutdownVite() + if gpus != nil { + gpuUsedSettled(before) + } + gpuMonitor.invalidate() + } + + res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg), + PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(), + Weights: tuneWeightsFrom(perfLog.snapshot())} + + // Référence : la ligne composée telle quelle, la sonde de la machine. + tr.setPhase("référence : composition de la ligne de commande") + baseDir, basePort, err := t.prepare(cfg) + if err != nil { + return nil, err + } + baseLaunch, err := t.dry(ctx, baseDir) + if err != nil { + return nil, fmt.Errorf("la configuration active ne se lance pas : %s", err) + } + probe := baseLaunch.Probe + if probe == nil { + probe = &tuneProbe{} + } + smi := gpuStats() + env := tuneEnv{Help: probe.Help, GPUs: probe.GPUs, VRAMMiB: probe.VRAMMiB, ModelBytes: probe.ModelBytes, + MoE: probe.MoE, PhysCores: tunePhysCores(probe), SMI: smi != nil, ArgEnv: probe.ArgEnv, + UserEnv: probe.UserEnv, QueuesEnv: probe.QueuesEnv, Placement: opts.Placement, OptIn: opts.OptIn} + visible := tuneVisibleGPUs(cfg) + if env.VRAMMiB == 0 && smi != nil { + for i, g := range smi { + if len(visible) == 0 || containsInt(visible, i) { + env.VRAMMiB += int64(g.Total) + } + } + } + if env.GPUs == 0 && smi != nil { + env.GPUs = len(smi) + if len(visible) > 0 { + env.GPUs = len(visible) + } + } + extra := splitArgs(cfg["EXTRA_ARGS"]) + kt, vt, _ := effectiveKVTypes(cfg, extra, env.ArgEnv) + if label, quant := benchKVLabel(kt, vt); quant { + res.Notes = append(res.Notes, "cache KV "+label+" quantifié : zone grise, conservé tel quel (jamais modifié par l'optimiseur)") + } + if !env.SMI { + res.Notes = append(res.Notes, "VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés") + } + if _, total := ramUsageMB(); total > 0 && env.ModelBytes > int64(total)<<20 { + res.Notes = append(res.Notes, "modèle plus gros que la RAM : chaque essai le relit depuis le disque — prévoir un budget "+ + "plus long, ou ne garder que quelques étapes") + } + reserve := tuneTightMiB + if strings.TrimSpace(cfg["MMPROJ"]) != "" { + reserve += tuneVisionReserveMiB + } + cpuPlaced := tensorOverride(extra, env.ArgEnv) != "" + + // La référence se mesure deux fois : son écart entre passages est le bruit + // que tout gain doit dépasser. + tr.setPhase("référence : chargement") + bi := tr.put(-1, tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "en cours", Headroom: -1}) + t0 := time.Now() + base, stop, err := t.load(ctx, baseDir, basePort, baseLaunch) + if err != nil { + stop() + return nil, fmt.Errorf("la référence ne charge pas : %s", err) + } + res.Engine, res.NCtx, res.Slots = base.build, base.nCtx, base.slots + if m := reBuildInfo.FindStringSubmatch(base.build); m != nil { + res.EngineBuild, _ = strconv.Atoi(m[1]) + } + // Une profondeur pour tous : celle que la référence peut tenir avec le plus + // gros préchauffage essayé (2 × 4096 jetons, au plus un quart du contexte). + warmMax := max(min(2*4096, base.nCtx/4), 64) + benchWarmGen + res.Depth, _ = benchDepthFor(base.nCtx, cpuPlaced, warmMax) + bopts := benchOpts{Mode: benchModeFull, Prompt: 2000, Predict: 300, Depth: res.Depth} + corp := defaultBenchCorpora() + measure := func(cfg map[string]string, l *tuneLoaded, n int, phase string) ([]tuneRun, error) { + var runs []tuneRun + for i := 0; i < n; i++ { + tr.setPhase("%s : mesure %d/%d", phase, i+1, n) + r, err := benchRun(ctx, l.eng, bopts, tuneBenchSetup(cfg, env.ArgEnv, cpuPlaced), corp, nil) + if err != nil { + return runs, err + } + run, err := tuneRunFrom(r, res.Weights) + if err != nil { + return runs, err + } + runs = append(runs, run) + } + return runs, nil + } + headroom := func() (int, bool) { + if !env.SMI { + return -1, false + } + free, ok := tuneHeadroom(gpuStats(), visible) + if !ok { + return -1, false + } + return free, free < reserve + } + baseRuns, err := measure(cfg, base, 2, "référence") + hr, _ := headroom() + stop() + if err != nil { + return nil, fmt.Errorf("mesure de la référence : %s", err) + } + bt := tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "mesuré", Runs: baseRuns, + TurnSec: turnMean(baseRuns), Headroom: hr, Sec: time.Since(t0).Seconds()} + if base.offloaded >= 0 { + bt.Offloaded = fmt.Sprintf("%d/%d", base.offloaded, base.layers) + } + res.Baseline = bt + tr.put(bi, bt) + if hr >= 0 && hr < reserve { + res.Notes = append(res.Notes, fmt.Sprintf("la configuration actuelle laisse déjà moins de %d Mio libres sur une carte", reserve)) + } + + best, bestRuns, bestLabel := cfg, baseRuns, "" + durations := []float64{bt.Sec} + measured := map[string]string{tuneLaunchKey(baseLaunch): bt.Label} + deadline := tr.started.Add(opts.Budget) + +stages: + for si, st := range tuneStages { + if !tuneStageWanted(st.Name, opts.Stages) { + res.Skipped = append(res.Skipped, st.Name+" : non demandé") + continue + } + cands, why := st.Gen(best, env) + if why != "" { + res.Skipped = append(res.Skipped, st.Name+" : "+why) + continue + } + for ci, c := range cands { + // Ce qui reste à faire, pour l'estimation : les essais restants de + // l'étape, plus ceux des étapes suivantes vus depuis la meilleure + // configuration du moment. + left := len(cands) - ci + for _, nx := range tuneStages[si+1:] { + if !tuneStageWanted(nx.Name, opts.Stages) { + continue + } + more, _ := nx.Gen(best, env) + left += len(more) + } + avg := tuneMean(durations) + tr.mu.Lock() + tr.avgSec, tr.left = avg, left + tr.mu.Unlock() + if ctx.Err() != nil { + res.Partial = "annulé" + break stages + } + if time.Until(deadline).Seconds() < avg { + res.Partial = fmt.Sprintf("budget de %s atteint : %d essai(s) non faits", opts.Budget.Round(time.Minute), left) + break stages + } + trial := tuneTrial{Stage: c.Stage, Label: c.Label, Status: "en cours", Headroom: -1, + Changes: tuneDiff(cfg, c.Cfg)} + idx := tr.put(-1, trial) + finish := func(status, why string) { + trial.Status, trial.Why = status, why + tr.put(idx, trial) + res.Trials = append(res.Trials, trial) + } + if err := tuneCfgCheck(cfg, c.Cfg, opts.OptIn); err != nil { + finish("écarté", "dénature : "+err.Error()) + continue + } + tr.setPhase("%s — %s : composition", st.Name, c.Label) + dir, port, err := t.prepare(c.Cfg) + if err != nil { + finish("échec", err.Error()) + continue + } + launch, err := t.dry(ctx, dir) + if err != nil { + if ctx.Err() != nil { + finish("échec", "annulé") + res.Partial = "annulé" + break stages + } + finish("écarté", "refusé au lancement : "+err.Error()) + continue + } + if why := tuneDenature(baseLaunch, launch, opts.OptIn); why != "" { + finish("écarté", "dénature : "+why) + continue + } + key := tuneLaunchKey(launch) + if prev, ok := measured[key]; ok { + finish("doublon", "même ligne de commande que « "+prev+" »") + continue + } + measured[key] = c.Label + t1 := time.Now() + tr.setPhase("%s — %s : chargement", st.Name, c.Label) + l, stop, err := t.load(ctx, dir, port, launch) + if err != nil { + stop() + if ctx.Err() != nil { + finish("échec", "annulé") + res.Partial = "annulé" + break stages + } + finish("échec", err.Error()) + durations = append(durations, time.Since(t1).Seconds()) + continue + } + trial.Sec = time.Since(t1).Seconds() + if l.offloaded >= 0 { + trial.Offloaded = fmt.Sprintf("%d/%d", l.offloaded, l.layers) + } + // Contre-vérification à l'exécution : même contexte, mêmes slots, + // mêmes types de cache que la référence. + switch { + case l.nCtx != base.nCtx || l.slots != base.slots: + stop() + finish("écarté", fmt.Sprintf("dénature : contexte %d × %d slot(s) au lieu de %d × %d", l.nCtx, l.slots, base.nCtx, base.slots)) + continue + case base.kv != "" && l.kv != "" && l.kv != base.kv: + stop() + finish("écarté", "dénature : cache KV "+l.kv+" au lieu de "+base.kv) + continue + } + fitRegressed := base.offloaded >= 0 && l.offloaded >= 0 && l.offloaded < base.offloaded + runs, err := measure(c.Cfg, l, 1, st.Name+" — "+c.Label) + if err == nil && len(runs) == 1 && tuneWorthRepeat(turnSecs(bestRuns), runs[0].TurnSec) && !fitRegressed { + var more []tuneRun + more, err = measure(c.Cfg, l, 1, st.Name+" — "+c.Label+" (confirmation)") + runs = append(runs, more...) + } + hr, tight := headroom() + stop() + trial.Sec = time.Since(t1).Seconds() + durations = append(durations, trial.Sec) + trial.Runs, trial.Headroom = runs, hr + if err != nil { + if ctx.Err() != nil { + finish("échec", "annulé") + res.Partial = "annulé" + break stages + } + finish("échec", "mesure : "+err.Error()) + continue + } + trial.TurnSec = turnMean(runs) + gain, need, wins := tuneWins(turnSecs(bestRuns), turnSecs(runs)) + trial.Gain = gain + proseBest, proseCand := proseMean(bestRuns), proseMean(runs) + switch { + case fitRegressed: + finish("écarté", fmt.Sprintf("fit en recul : %d couches déportées au lieu de %d (performance)", l.offloaded, base.offloaded)) + case tight: + finish("écarté", fmt.Sprintf("trop juste : %d Mio libres sur une carte (< %d)", hr, reserve)) + case proseBest > 0 && proseCand < 0.95*proseBest: + finish("mesuré", fmt.Sprintf("decode en prose plus lent de %.0f %%", (1-proseCand/proseBest)*100)) + case wins: + best, bestRuns, bestLabel = c.Cfg, runs, c.Label + if c.Stage == "placement" { + res.Placement = true + } + finish("retenu", fmt.Sprintf("−%.1f %% par tour (seuil %.1f %%)", gain*100, need*100)) + case len(runs) < 2 && gain > 0: + finish("mesuré", fmt.Sprintf("−%.1f %%, sous le seuil de %.0f %%", gain*100, tuneMinGain*100)) + default: + delta := fmt.Sprintf("%+.1f %%", -gain*100) + if gain*1000 > -0.5 && gain*1000 < 0.5 { + delta = "≈ 0 %" + } + finish("mesuré", delta+fmt.Sprintf(" (seuil %.1f %%)", need*100)) + } + } + } + tr.mu.Lock() + tr.left = 0 + tr.mu.Unlock() + + res.Best = bestLabel + if bestLabel != "" { + res.Gain = (turnMean(baseRuns) - turnMean(bestRuns)) / turnMean(baseRuns) + res.Changes = tuneDiff(cfg, best) + res.Set, res.Old = map[string]string{}, map[string]string{} + for k := range keysOf(cfg, best) { + if cfg[k] != best[k] { + res.Set[k], res.Old[k] = best[k], cfg[k] + } + } + } + if ctx.Err() != nil && res.Partial == "" { + res.Partial = "annulé" + } + saveTuneResult(res) + return res, nil +} + +func turnSecs(r []tuneRun) []float64 { + out := make([]float64, len(r)) + for i, x := range r { + out[i] = x.TurnSec + } + return out +} + +func turnMean(r []tuneRun) float64 { return tuneMean(turnSecs(r)) } + +func proseMean(r []tuneRun) float64 { + v := make([]float64, len(r)) + for i, x := range r { + v[i] = x.ProseTG + } + return tuneMean(v) +} + +func keysOf(ms ...map[string]string) map[string]bool { + out := map[string]bool{} + for _, m := range ms { + for k := range m { + out[k] = true + } + } + return out +} + +// --- résultats enregistrés ------------------------------------------------------- + +// saveTuneResult garde le dernier résultat par preset, avec l'empreinte et le +// build du moteur : après une mise à jour du moteur, l'interface propose de +// relancer l'optimisation. +func saveTuneResult(res *tuneResult) { + m := map[string]tuneResult{} + getJSON(bkState, "tune_results", &m) + if m == nil { + m = map[string]tuneResult{} + } + m[res.PresetID] = *res + _ = putJSON(bkState, "tune_results", m) +} + +func loadTuneResult(id string) (*tuneResult, bool) { + m := map[string]tuneResult{} + if !getJSON(bkState, "tune_results", &m) { + return nil, false + } + r, ok := m[id] + if !ok { + return nil, false + } + return &r, true +} + +// --- application ------------------------------------------------------------------ + +// tuneApplyTarget : où écrire le résultat. +const ( + tuneApplyCopy = "copy" // une copie du preset, non activée + tuneApplyPreset = "preset" // le preset mesuré lui-même, réappliqué et vérifié +) + +// tunePlanApply prépare la réécriture sans rien écrire : le contenu du preset +// réglé et le diff des clés. Refusé si le preset a changé depuis la mesure. +func tunePlanApply(res *tuneResult) (content, patched string, err error) { + if res == nil || len(res.Set) == 0 { + return "", "", errors.New("rien à appliquer : la configuration actuelle reste la meilleure") + } + content, err = ReadPreset(res.PresetID) + if err != nil { + return "", "", fmt.Errorf("preset « %s » introuvable : %w", res.PresetName, err) + } + if presetFingerprint([]byte(content)) != res.PresetFP { + return "", "", errors.New("le preset a changé depuis la mesure : relance l'optimisation") + } + base := parseEnv(content) + win := copyCfg(base) + for k, v := range res.Set { + if v == "" { + delete(win, k) + } else { + win[k] = v + } + } + if err := tuneCfgCheck(base, win, res.OptIn); err != nil { + return "", "", err + } + patched, err = tunePatchPreset(content, base, win) + return content, patched, err +} + +// tuneApply écrit le résultat : dans une COPIE du preset (rien d'autre ne +// bouge), ou dans le preset lui-même — il doit être encore actif. Dans ce cas +// le moteur redémarre sur la nouvelle version, qui doit répondre, garder son +// contexte et ses slots, et passer une sonde (un prompt à la profondeur mesurée +// puis un decode) ; sinon l'ancienne version est rétablie. L'appelant a la +// confirmation de l'utilisateur. +func tuneApply(ctx context.Context, res *tuneResult, target string, say func(string)) (string, error) { + content, patched, err := tunePlanApply(res) + if err != nil { + return "", err + } + switch target { + case tuneApplyCopy: + name := res.PresetName + " (optimisé)" + id, err := SavePreset("", name, patched) + if err != nil { + return "", err + } + return fmt.Sprintf("copie « %s » créée (%s) — bascule dessus pour l'utiliser", name, id), nil + case tuneApplyPreset: + default: + return "", fmt.Errorf("cible inconnue : %s", target) + } + if externalActive() { + return "", errors.New("le preset actif est une API externe") + } + cur := ReadConfig() + if presetFingerprint([]byte(content)) != configFingerprint(cur) { + return "", errors.New("ce preset n'est plus celui en service : bascule dessus, ou enregistre le résultat dans une copie") + } + if configFingerprint(cur) != res.BaseFP { + return "", errors.New("la configuration active a changé depuis la mesure : relance l'optimisation") + } + lock, err := tuneLockAcquire("apply", serviceIsActive()) + if err != nil { + return "", err + } + defer lock.release() + lock.setPhase("application") + backup := filepath.Join(LokiHome(), "tune", "backup", fmt.Sprintf("%s-%d.env", res.PresetID, time.Now().Unix())) + _ = os.MkdirAll(filepath.Dir(backup), 0o700) + if err := os.WriteFile(backup, []byte(content), 0o600); err != nil { + return "", fmt.Errorf("sauvegarde du preset : %w", err) + } + revert := func(why string) (string, error) { + say("échec (" + why + ") : retour à l'ancienne version") + if _, _, err := SavePresetApplying(res.PresetID, res.PresetName, content); err != nil { + return "", fmt.Errorf("%s ; RETOUR IMPOSSIBLE (%v) — l'ancienne version est dans %s", why, err, backup) + } + if err := tuneSvc("restart"); err != nil { + return "", fmt.Errorf("%s ; ancienne version remise, redémarrage : %s", why, plainErr(err)) + } + return "", fmt.Errorf("%s : ancienne version rétablie", why) + } + say("écriture du preset (sauvegarde : " + backup + ")") + _, applied, err := SavePresetApplying(res.PresetID, res.PresetName, patched) + if err != nil { + return revert("écriture : " + err.Error()) + } + if !applied { + return revert("le preset n'a pas été appliqué à la configuration active") + } + // Même configuration que l'essai retenu, à l'empreinte près : même + // composition de la ligne de commande (buildServeArgs) que mesurée. + if why := tuneAppliedMismatch(ReadConfig(), res); why != "" { + return revert(why) + } + say("redémarrage du moteur") + if err := tuneSvc("restart"); err != nil { + return revert("redémarrage : " + plainErr(err)) + } + if err := tuneProbeFn(ctx, res, say); err != nil { + return revert(err.Error()) + } + return "preset « " + res.PresetName + " » optimisé et vérifié", nil +} + +// tuneSvc et tuneProbeFn : serviceActionOS et tunePostApplyProbe, remplaçables +// dans les tests (application puis retour arrière sans vrai moteur). +var ( + tuneSvc = serviceActionOS + tuneProbeFn = tunePostApplyProbe +) + +// tuneAppliedMismatch : la configuration active après application doit être +// EXACTEMENT celle de l'essai retenu — la référence mesurée plus les clés +// réglées. Remises à leur valeur de référence, on doit retrouver l'empreinte +// mesurée. Même configuration, même composition (buildServeArgs) : le moteur +// relancé est bien celui qui a gagné. "" = conforme. +func tuneAppliedMismatch(now map[string]string, res *tuneResult) string { + back := copyCfg(now) + for k, v := range res.Set { + if now[k] != v { + return "la configuration appliquée diffère de l'essai retenu sur " + k + } + if old := res.Old[k]; old == "" { + delete(back, k) + } else { + back[k] = old + } + } + if configFingerprint(back) != res.BaseFP { + return "la configuration a changé ailleurs que sur les clés réglées depuis la mesure" + } + return "" +} + +// tunePostApplyProbe : le moteur redémarré répond, garde le contexte et les +// slots mesurés, et tient un prompt à la profondeur de la mesure suivi d'un +// decode — ce qu'un /health seul ne montre pas (un OOM au premier long prompt). +func tunePostApplyProbe(ctx context.Context, res *tuneResult, say func(string)) error { + say("attente du moteur") + e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient} + deadline := time.Now().Add(tuneLoadTimeout) + for !healthCheck() { + if time.Now().After(deadline) { + return fmt.Errorf("le moteur ne répond pas après %s", tuneLoadTimeout) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } + nCtx, slots, _, err := tuneProps(ctx, e) + if err != nil { + return fmt.Errorf("/props illisible : %w", err) + } + if nCtx != res.NCtx || slots != res.Slots { + return fmt.Errorf("contexte %d × %d slot(s) au lieu de %d × %d", nCtx, slots, res.NCtx, res.Slots) + } + if res.Depth <= 0 { + return nil + } + say(fmt.Sprintf("sonde : prompt de %d jetons puis decode", res.Depth)) + cfg := ReadConfig() + s := tuneBenchSetup(cfg, nil, false) + code := defaultBenchCorpora().code + sample, _ := benchSlice(code, 0, benchSampleBytes) + text, _ := benchSlice(code, 0, int(float64(res.Depth)*e.bytesPerToken(ctx, sample))) + defer engineSideJob()() + if _, err := s.ask(ctx, e, []Message{{Role: "user", Content: text + "\n\n" + benchAskFirst}}, 64, false); err != nil { + return fmt.Errorf("sonde : %w", err) + } + return nil +} diff --git a/internal/loki/backend_tune_test.go b/internal/loki/backend_tune_test.go new file mode 100644 index 0000000..7e5aa63 --- /dev/null +++ b/internal/loki/backend_tune_test.go @@ -0,0 +1,717 @@ +package loki + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" +) + +// Aide d'un moteur récent, réduite à ce que lisent les tests de capacité. +const tuneTestHelp = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number, 'auto', or 'all' +-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory +-fitt, --fit-target MiB0,MiB1,... target margin per device for --fit +--spec-type [none,draft-simple,draft-mtp,ngram-mod] +--spec-draft-n-max N +-bs, --backend-sampling +--load-mode {auto,none,mmap,mlock,mmap+mlock,dio} +` + +// Le découpage avec positions doit donner exactement les jetons de splitArgs : +// c'est ce qui garantit qu'une réécriture ne touche qu'aux jetons visés. +func TestTuneArgSpansMatchSplitArgs(t *testing.T) { + for _, s := range []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`, `"" -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`, + } { + var got []string + for _, sp := range argSpans(s) { + got = append(got, sp.val) + } + want := splitArgs(s) + if len(got) == 0 && len(want) == 0 { + continue + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%q : argSpans %q, splitArgs %q", s, got, want) + } + } +} + +// Les jetons hors liste blanche ressortent à l'octet près, guillemets compris, +// quelle que soit la réécriture. +func TestTuneExtraEditsKeepOtherTokens(t *testing.T) { + keep := []string{"--cache-type-k q8_0", "--flash-attn on", `--chat-template-file "/a b/t.jinja"`, "-ctv q8_0"} + extra := `-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` + edits := map[string]string{ + "set -ub": tuneExtraSet(extra, knobUBatch.flags, "2048"), + "drop -ub": tuneExtraSet(extra, knobUBatch.flags, ""), + "add -t": tuneExtraSet(extra, knobThreads.flags, "7"), + "drop -ts": tuneExtraDrop(extra, 1, "-ts", "--tensor-split"), + "add switch": tuneExtraAddSwitch(extra, "--backend-sampling"), + "drop expert": func() string { s, _ := tuneExtraDropExperts(extra); return s }(), + } + for name, out := range edits { + for _, k := range keep { + if !strings.Contains(out, k) { + t.Errorf("%s : %q perdu dans %q", name, k, out) + } + } + if err := tuneCfgCheck(map[string]string{"EXTRA_ARGS": extra}, map[string]string{"EXTRA_ARGS": out}, true); err != nil { + t.Errorf("%s : %v (%q)", name, err, out) + } + } + if out := edits["set -ub"]; !strings.Contains(out, "-ub 2048") || strings.Contains(out, "-ub 512") { + t.Errorf("-ub non réglé sur place : %q", out) + } + out, removed := tuneExtraDropExperts(extra) + if strings.Contains(out, "exps") || strings.Contains(out, "--n-cpu-moe") || strings.Contains(out, "-cmoe") { + t.Errorf("experts encore placés à la main : %q", out) + } + if !strings.Contains(out, "-ot per_layer_token_embd=CPU") || len(removed) != 3 { + t.Errorf("élément non expert du -ot perdu, ou retraits mal comptés : %q %q", out, removed) + } + if got := tuneExtraSet("-ub=512 -b 4096", knobUBatch.flags, "1024"); got != "-ub=1024 -b 4096" { + t.Errorf("forme -ub= : %q", got) + } +} + +// Un drapeau interdit (cache KV, flash-attn, contexte…) ne fait jamais partie de +// la liste blanche, et une clé intouchable n'est jamais réglable. +func TestTuneWhitelistDisjoint(t *testing.T) { + for _, f := range tuneForbiddenFlags { + if tuneFlagFor(f, true) != nil { + t.Errorf("%s est à la fois interdit et réglable", f) + } + } + for _, k := range tuneNeverKeys { + if tuneKeys[k] || tuneOptInKeys[k] { + t.Errorf("%s est à la fois intouchable et réglable", k) + } + } + base := map[string]string{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0"} + for _, c := range []map[string]string{ + {"CTX": "65536", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0"}, + {"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k f16"}, + {"CTX": "32768", "EXTRA_ARGS": "-ub 512"}, + {"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "KV_TYPE": "q4_0"}, + {"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "SPEC": "auto"}, // opt-in non coché + } { + if tuneCfgCheck(base, c, false) == nil { + t.Errorf("changement interdit accepté : %v", c) + } + } + if err := tuneCfgCheck(base, map[string]string{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "SPEC": "auto"}, true); err != nil { + t.Errorf("SPEC refusé avec l'opt-in : %v", err) + } +} + +func tuneRichCfg() map[string]string { + return map[string]string{ + "MODEL": "big.gguf", "CTX": "65536", "KV_TYPE": "q8_0", "MMPROJ": "mmproj.gguf", "REASONING": "on", + "REASONING_BUDGET": "-1", "TEMP": "0.6", "TOP_K": "20", "PARALLEL": "1", "SPEC": "mtp", + "EXTRA_ARGS": `-ot "blk\.([0-9]+)\.ffn_.*_exps\.=CPU" --n-cpu-moe 40 --cache-type-k q8_0 --flash-attn on -ub 512 -t 8 -ts 0.6,0.4`, + } +} + +func tuneRichEnv() tuneEnv { + return tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 80 << 30, MoE: true, PhysCores: 8, + SMI: true, Placement: true, OptIn: true} +} + +// La grille : chaque essai ne change que des clés et des drapeaux permis, ne +// touche à aucune clé intouchable, et garde les drapeaux interdits à l'octet. +func TestTuneGridRespectsNeverTouch(t *testing.T) { + base := tuneRichCfg() + env := tuneRichEnv() + total := 0 + for _, st := range tuneStages { + cands, why := st.Gen(base, env) + if why != "" { + continue + } + for _, c := range cands { + total++ + if err := tuneCfgCheck(base, c.Cfg, env.OptIn); err != nil { + t.Errorf("%s / %s : %v", st.Name, c.Label, err) + } + for _, k := range tuneNeverKeys { + if c.Cfg[k] != base[k] { + t.Errorf("%s / %s : %s modifié (%q → %q)", st.Name, c.Label, k, base[k], c.Cfg[k]) + } + } + for _, k := range []string{"--cache-type-k q8_0", "--flash-attn on"} { + if !strings.Contains(c.Cfg["EXTRA_ARGS"], k) { + t.Errorf("%s / %s : %q perdu", st.Name, c.Label, k) + } + } + } + } + if total == 0 { + t.Fatal("aucun essai généré") + } +} + +// Chaque axe n'existe que si le moteur, la machine et l'utilisateur le +// permettent. +func TestTuneGridGating(t *testing.T) { + base := tuneRichCfg() + gen := func(name string, env tuneEnv) ([]tuneCand, string) { + for _, st := range tuneStages { + if st.Name == name { + return st.Gen(base, env) + } + } + t.Fatalf("étape %s inconnue", name) + return nil, "" + } + env := tuneRichEnv() + env.Placement = false + if _, why := gen("placement", env); why == "" { + t.Error("placement essayé sans « inclure le placement »") + } + env = tuneRichEnv() + env.Help = strings.ReplaceAll(tuneTestHelp, "--fit", "--nofit") + if _, why := gen("placement", env); why == "" { + t.Error("placement essayé sur un moteur sans --fit") + } + env = tuneRichEnv() + if c, why := gen("placement", env); why != "" || len(c) != 1 { + t.Fatalf("placement attendu : %q", why) + } + // Marges --fit : refusées tant que le placement manuel coupe --fit, ou sans + // --fit-target dans l'aide. + if _, why := gen("marges", env); why == "" { + t.Error("marges essayées alors que -ot / -ts coupent --fit") + } + plain := map[string]string{"MODEL": "m.gguf", "CTX": "32768"} + for _, st := range tuneStages { + if st.Name != "marges" { + continue + } + if c, why := st.Gen(plain, env); why != "" || len(c) != 3 { + t.Errorf("marges sur 2 cartes : %d essai(s), %q", len(c), why) + } + e2 := env + e2.Help = strings.ReplaceAll(tuneTestHelp, "--fit-target", "--fit-x") + if _, why := st.Gen(plain, e2); why == "" { + t.Error("marges essayées sans --fit-target") + } + e2 = env + e2.SMI = false + if _, why := st.Gen(plain, e2); why == "" { + t.Error("marges essayées sans mesure de VRAM") + } + } + // Lots : sans mesure de VRAM, jamais de micro-lot plus grand. + env = tuneRichEnv() + env.SMI = false + cands, _ := gen("lots", env) + for _, c := range cands { + if serveUBatch(c.Cfg, splitArgs(c.Cfg["EXTRA_ARGS"])) > 512 { + t.Errorf("micro-lot monté sans mesure de VRAM : %s", c.Label) + } + } + // -ub dans EXTRA_ARGS : réglé là, jamais par une clé qu'il écraserait. + cands, _ = gen("lots", tuneRichEnv()) + for _, c := range cands { + if c.Cfg["UBATCH"] != "" { + t.Errorf("%s : UBATCH posé alors qu'EXTRA_ARGS fixe -ub", c.Label) + } + } + // Deux cartes tout GPU : le lot reste plus grand que le micro-lot. + dense := map[string]string{"MODEL": "m.gguf"} + env2 := tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 10 << 30, SMI: true} + for _, st := range tuneStages { + if st.Name != "lots" { + continue + } + cs, _ := st.Gen(dense, env2) + for _, c := range cs { + b, _ := strconv.Atoi(knobBatch.get(c.Cfg)) + ub, _ := strconv.Atoi(knobUBatch.get(c.Cfg)) + if b <= ub { + t.Errorf("%s : lot pas plus grand que le micro-lot sur 2 cartes", c.Label) + } + } + } + for _, name := range []string{"threads", "délestage"} { + for _, st := range tuneStages { + if st.Name == name { + if _, why := st.Gen(dense, env2); why == "" { + t.Errorf("%s essayé alors que tous les poids sont sur GPU", name) + } + } + } + } + // Opt-in : rien sans la case ; avec, --backend-sampling et SPEC_N_MAX. + env = tuneRichEnv() + env.OptIn = false + if _, why := gen("opt-in", env); why == "" { + t.Error("opt-in essayé sans la case") + } + cands, _ = gen("opt-in", tuneRichEnv()) + labels := map[string]bool{} + for _, c := range cands { + labels[c.Label] = true + } + for _, want := range []string{"--backend-sampling", "SPEC_N_MAX=2", "SPEC_N_MAX=4", "CUDA_GRAPH_OPT=on"} { + if !labels[want] { + t.Errorf("opt-in : %s manquant (%v)", want, labels) + } + } + // Threads : « omis » = aucun -t, jamais THREADS=0. + cands, _ = gen("threads", tuneRichEnv()) + found := false + for _, c := range cands { + if c.Label == "threads omis (cœurs physiques)" { + found = true + if hasAnyFlag(splitArgs(c.Cfg["EXTRA_ARGS"]), "-t") || c.Cfg["THREADS"] != "" { + t.Errorf("threads omis mais -t ou THREADS encore là : %v", c.Cfg) + } + } + } + if !found { + t.Error("candidat « threads omis » absent") + } +} + +// La comparaison des lignes de commande : un réglage permis passe, un drapeau +// de fidélité ou une variable non réglée changés sont « dénature ». +func TestTuneDenature(t *testing.T) { + 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 := base + ok.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"} + ok.Env = map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_OP_OFFLOAD_MIN_BATCH": "128"} + if why := tuneDenature(base, ok, false); why != "" { + t.Errorf("réglages permis jugés dénaturants : %s", why) + } + for name, mut := range map[string]func(l *tuneLaunch){ + "ctx": func(l *tuneLaunch) { l.Args[4] = "32768" }, + "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" }, + "spec": func(l *tuneLaunch) { l.Args = append(l.Args, "--spec-draft-n-max", "4") }, + } { + c := ok + c.Args = append([]string(nil), ok.Args...) + c.Env = map[string]string{} + for k, v := range ok.Env { + c.Env[k] = v + } + mut(&c) + if tuneDenature(base, c, false) == "" { + t.Errorf("%s : changement non vu", name) + } + } + // Opt-in coché : la spéculation et les graphes CUDA deviennent réglables. + c := ok + c.Args = append(append([]string(nil), ok.Args...), "--spec-draft-n-max", "4") + c.Env = map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_CUDA_GRAPH_OPT": "1"} + if why := tuneDenature(base, c, true); why != "" { + t.Errorf("opt-in : %s", why) + } +} + +func TestTuneLogFacts(t *testing.T) { + log := "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" + off, total, kv := tuneLogFacts(log) + if off != 49 || total != 49 || kv != "q8_0/q8_0" { + t.Errorf("faits du journal : %d/%d %q", off, total, kv) + } + if off, _, kv := tuneLogFacts("rien"); off != -1 || kv != "" { + t.Errorf("journal vide : %d %q", off, kv) + } + if !strings.Contains(tuneLoadFailure("ggml_backend_cuda_buffer_type_alloc_buffer: cudaMalloc failed: out of memory"), "OOM") { + t.Error("OOM non reconnu") + } +} + +// Le score et le départage : un gain ne compte qu'au-delà de 3 % ET de +// l'écart entre passages, et sur deux passages. +func TestTuneScoreAndTieBreak(t *testing.T) { + w := tuneWeights{K: 2000, G: 600, FCold: 0.05} + res := &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}}}} + r, err := tuneRunFrom(res, w) + if err != nil { + t.Fatal(err) + } + // Jetons neufs réels : (2100+30000-30048)+(2100+32300-32348) = 4104 en 4 s. + if r.CachedPP < 1025 || r.CachedPP > 1027 { + t.Errorf("prefill des tours : %.1f", r.CachedPP) + } + want := 2000/r.CachedPP + 600/40.0 + 0.05*32768/800 + if d := r.TurnSec - want; d > 1e-9 || d < -1e-9 { + t.Errorf("durée d'un tour : %.3f au lieu de %.3f", r.TurnSec, want) + } + res.Depth.Partial = "contexte plein" + if _, err := tuneRunFrom(res, w); err == nil { + t.Error("mesure partielle acceptée") + } + base := []float64{20, 20.4} + if _, _, ok := tuneWins(base, []float64{19.7, 19.8}); ok { + t.Error("gain de ~2 % accepté (sous le seuil)") + } + if _, _, ok := tuneWins(base, []float64{18}); ok { + t.Error("un seul passage suffit à gagner") + } + if g, _, ok := tuneWins(base, []float64{18, 18.1}); !ok || g < 0.09 { + t.Errorf("gain net refusé (%.3f)", g) + } + // Écart entre passages plus grand que le gain : refusé. + if _, need, ok := tuneWins([]float64{18, 22}, []float64{18.5, 18.6}); ok || need < 0.19 { + t.Errorf("bruit non pris en compte (seuil %.2f)", need) + } + if !tuneWorthRepeat(base, 19) || tuneWorthRepeat(base, 19.8) { + t.Error("confirmation mal décidée") + } +} + +// La réécriture du preset ne change que les clés réglées ; commentaires, NAME +// et autres clés restent, EXTRA_ARGS garde ses jetons interdits. +func TestTunePatchPreset(t *testing.T) { + 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" + base := parseEnv(content) + win := knobThreads.set(base, "") + win = setKey(win, "UBATCH", "2048") + win = setKey(win, "BATCH", "4096") + out, err := tunePatchPreset(content, base, win) + if err != nil { + t.Fatal(err) + } + for _, s := range []string{"# NAME=Gros MoE", "# commentaire", "MODEL=big.gguf", "CTX=65536", "UBATCH=2048", "BATCH=4096", + "--cache-type-k q8_0 --flash-attn on"} { + if !strings.Contains(out, s) { + t.Errorf("%q absent de :\n%s", s, out) + } + } + if strings.Contains(out, "-t 8") { + t.Errorf("-t non retiré :\n%s", out) + } + bad := setKey(base, "CTX", "32768") + if _, err := tunePatchPreset(content, base, bad); err == nil { + t.Error("clé intouchable réécrite") + } +} + +// Le verrou : exclusif entre processus, refusé tant que son propriétaire vit, +// écarté quand il est mort ; et serviceAction refuse tout démarrage pendant ce +// temps, sur toutes les plateformes, avant de toucher au système. +func TestTuneLock(t *testing.T) { + testHome(t) + alive := true + prev := tuneProcAlive + tuneProcAlive = func(p tuneProc) bool { return alive && p.PID == os.Getpid() } + t.Cleanup(func() { tuneProcAlive = prev }) + + if err := tuneGuard(); err != nil { + t.Fatalf("garde levée sans verrou : %v", err) + } + l, err := tuneLockAcquire("cli", true) + if err != nil { + t.Fatal(err) + } + if !errors.Is(tuneGuard(), errTuneBusy) { + t.Error("garde muette pendant l'optimisation") + } + if _, err := tuneLockAcquire("web", false); err == nil { + t.Error("second verrou accordé") + } + for _, a := range []string{"start", "restart"} { + if err := serviceAction(a); !errors.Is(err, errTuneBusy) { + t.Errorf("serviceAction(%s) pendant l'optimisation : %v", a, err) + } + } + if err := SwitchToPreset(filepath.Join(t.TempDir(), "x.env")); !errors.Is(err, errTuneBusy) { + t.Errorf("bascule de preset pendant l'optimisation : %v", err) + } + l.setTrial(&tuneProc{PID: 999999, Start: "x"}) + if o, ok := readTuneOwner(tuneLockPath()); !ok || o.Trial == nil || o.Trial.PID != 999999 || !o.MainWasActive { + t.Errorf("essai non noté dans le verrou : %+v", o) + } + l.release() + if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) { + t.Error("verrou non rendu") + } + + // Propriétaire mort : le verrou est périmé, il ne bloque rien et cède la + // place. L'essai noté n'est pas tué (identité non concordante). + l, err = tuneLockAcquire("cli", true) + if err != nil { + t.Fatal(err) + } + l.setTrial(&tuneProc{PID: 999999, Start: "x"}) + alive = false + if err := tuneGuard(); err != nil { + t.Errorf("verrou périmé encore bloquant : %v", err) + } + if !tuneReapStale() { + t.Error("MainWasActive perdu au nettoyage") + } + if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) { + t.Error("verrou périmé non retiré") + } + alive = true + l2, err := tuneLockAcquire("web", false) + if err != nil { + t.Fatalf("verrou refusé après nettoyage : %v", err) + } + l2.release() + // Un verrou d'un autre propriétaire n'est jamais retiré par release. + l3, _ := tuneLockAcquire("cli", false) + other := &tuneLock{path: tuneLockPath(), owner: tuneOwner{Owner: tuneProc{PID: os.Getpid(), Start: "autre"}}} + other.release() + if _, err := os.Stat(tuneLockPath()); err != nil { + t.Error("verrou d'autrui retiré") + } + l3.release() +} + +// Le moteur d'essai ne lit ni n'écrit config.env : « loki serve » à blanc, avec +// la configuration de l'essai, compose sa ligne de commande (adresse privée, +// surcharges) et laisse la configuration active intacte. +func TestTuneTrialNeverWritesConfig(t *testing.T) { + home := testHome(t) + for _, k := range []string{"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "CUDA_VISIBLE_DEVICES", "CUDA_DEVICE_ORDER", + "CUDA_SCALE_LAUNCH_QUEUES", "GGML_OP_OFFLOAD_MIN_BATCH", "GGML_CUDA_GRAPH_OPT", tuneTrialEnv, tuneDryRunEnv} { + t.Setenv(k, os.Getenv(k)) + } + bin := filepath.Join(home, "llama-server-factice") + model := filepath.Join(home, "m.gguf") + for _, p := range []string{bin, model} { + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + active := map[string]string{"BIN": bin, "MODEL": model, "CTX": "8192", "UBATCH": "512", "PORT": "8080", + "EXTRA_ARGS": "--cache-type-k q8_0 --port 9999"} + if err := WriteConfig(active); err != nil { + t.Fatal(err) + } + tr := &tuneTrialer{dir: filepath.Join(home, "tune", "run")} + trial := knobUBatch.set(active, "2048") + dir, port, err := tr.prepare(trial) + if err != nil { + t.Fatal(err) + } + t.Setenv(tuneTrialEnv, dir) + t.Setenv(tuneDryRunEnv, "1") + if err := cmdServe(nil); err != nil { + t.Fatalf("loki serve à blanc : %v", err) + } + if got := ReadConfig(); !reflect.DeepEqual(got, active) { + t.Errorf("configuration active modifiée :\n%v\nau lieu de\n%v", got, active) + } + l, err := readTuneLaunch(dir) + if err != nil { + t.Fatal(err) + } + if argValue(l.Args, "-ub") != "2048" || argValue(l.Args, "--host") != "127.0.0.1" || + argValue(l.Args, "--port") != strconv.Itoa(port) || argValue(l.Args, "--cache-type-k") != "q8_0" { + t.Errorf("ligne de l'essai inattendue : %q", l.Args) + } + for i, a := range l.Args { + if a == "--port" && l.Args[i+1] == "9999" { + t.Errorf("--port d'EXTRA_ARGS non forcé : %q", l.Args) + } + } + if l.Probe == nil { + t.Error("sonde absente de la ligne de l'essai") + } + if _, err := os.Stat(filepath.Join(home, "slots")); err == nil { + t.Error("l'essai a touché au dossier des slots du vrai moteur") + } +} + +// Après application, la configuration doit être exactement la référence plus +// les clés réglées. +func TestTuneAppliedMismatch(t *testing.T) { + base := map[string]string{"MODEL": "m.gguf", "UBATCH": "512", "EXTRA_ARGS": "-t 8"} + res := &tuneResult{BaseFP: configFingerprint(base), Set: map[string]string{"UBATCH": "2048", "EXTRA_ARGS": ""}, + Old: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-t 8"}} + if why := tuneAppliedMismatch(map[string]string{"MODEL": "m.gguf", "UBATCH": "2048"}, res); why != "" { + t.Errorf("application conforme refusée : %s", why) + } + if why := tuneAppliedMismatch(map[string]string{"MODEL": "m.gguf", "UBATCH": "1024"}, res); why == "" { + t.Error("valeur réglée différente non vue") + } + if why := tuneAppliedMismatch(map[string]string{"MODEL": "autre.gguf", "UBATCH": "2048"}, res); why == "" { + t.Error("changement hors réglages non vu") + } +} + +// Application : la copie ne touche pas au preset d'origine ; le preset lui-même +// est réécrit, le moteur redémarré, et une sonde qui échoue (OOM au premier +// long prompt, faux moteur) ramène l'ancienne version — preset ET configuration. +func TestTuneApplyAndRevert(t *testing.T) { + testHome(t) + content := "# NAME=Dense\nMODEL=m.gguf\nUBATCH=512\n" + if err := os.MkdirAll(presetsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(presetsDir(), "Dense.env"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := WriteConfig(parseEnv(content)); err != nil { + t.Fatal(err) + } + res := &tuneResult{PresetID: "Dense", PresetName: "Dense", PresetFP: presetFingerprint([]byte(content)), + BaseFP: configFingerprint(parseEnv(content)), Set: map[string]string{"UBATCH": "2048"}, + Old: map[string]string{"UBATCH": "512"}, Best: "UBATCH=2048"} + var calls []string + prevSvc, prevProbe := tuneSvc, tuneProbeFn + t.Cleanup(func() { tuneSvc, tuneProbeFn = prevSvc, prevProbe }) + tuneSvc = func(a string) error { calls = append(calls, a); return nil } + + if _, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {}); err != nil { + t.Fatalf("copie : %v", err) + } + if got, _ := ReadPreset("Dense"); got != content || len(calls) != 0 { + t.Fatalf("la copie a touché au preset ou au moteur : %q %v", got, calls) + } + copyBody, err := ReadPreset("Dense (optimisé)") + if err != nil || !strings.Contains(copyBody, "UBATCH=2048") { + t.Fatalf("copie absente ou fausse : %q %v", copyBody, err) + } + + tuneProbeFn = func(context.Context, *tuneResult, func(string)) error { return errors.New("sonde : out of memory") } + _, err = tuneApply(context.Background(), res, tuneApplyPreset, func(string) {}) + if err == nil || !strings.Contains(err.Error(), "rétablie") { + t.Fatalf("échec de la sonde sans retour arrière : %v", err) + } + if got, _ := ReadPreset("Dense"); got != content { + t.Errorf("preset non rétabli : %q", got) + } + if ReadConfig()["UBATCH"] != "512" { + t.Errorf("configuration non rétablie : %v", ReadConfig()) + } + if !reflect.DeepEqual(calls, []string{"restart", "restart"}) { + t.Errorf("redémarrages : %v", calls) + } + if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) { + t.Error("verrou non rendu après l'application") + } + + calls = nil + tuneProbeFn = func(context.Context, *tuneResult, func(string)) error { return nil } + if _, err := tuneApply(context.Background(), res, tuneApplyPreset, func(string) {}); err != nil { + t.Fatalf("application : %v", err) + } + if got, _ := ReadPreset("Dense"); !strings.Contains(got, "UBATCH=2048") || !strings.Contains(got, "# NAME=Dense") { + t.Errorf("preset non réglé : %q", got) + } + if ReadConfig()["UBATCH"] != "2048" || len(calls) != 1 { + t.Errorf("configuration %v, redémarrages %v", ReadConfig(), calls) + } + // Le preset a changé depuis la mesure : plus rien ne s'applique. + if _, err := tuneApply(context.Background(), res, tuneApplyPreset, func(string) {}); err == nil { + t.Error("résultat appliqué sur un preset modifié depuis la mesure") + } +} + +// TestTuneHelperSleep n'est pas un test : relancé par TestTuneReapOrphan comme +// faux essai orphelin, il dort jusqu'à ce qu'on le tue. +func TestTuneHelperSleep(t *testing.T) { + if os.Getenv("LOKI_TUNE_TEST_SLEEP") != "1" { + t.Skip("processus auxiliaire") + } + time.Sleep(60 * time.Second) +} + +// Un essai orphelin (propriétaire du verrou mort) est arrêté avant tout +// démarrage du moteur — seulement si son identité concorde. +func TestTuneReapOrphan(t *testing.T) { + testHome(t) + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(self, "-test.run=^TestTuneHelperSleep$") + cmd.Env = append(os.Environ(), "LOKI_TUNE_TEST_SLEEP=1") + tuneTrialAttr(cmd) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { _ = cmd.Wait(); close(done) }() + t.Cleanup(func() { _ = cmd.Process.Kill() }) + start, exe, ok := procIdentity(cmd.Process.Pid) + if !ok { + t.Fatal("identité du processus auxiliaire illisible") + } + // Propriétaire mort : un PID qui ne tourne plus (le test lui-même, avec un + // instant de démarrage qui n'est pas le sien). + dead := tuneProc{PID: os.Getpid(), Start: "pas-moi"} + write := func(trial tuneProc) { + b, _ := json.Marshal(tuneOwner{Owner: dead, Via: "cli", Trial: &trial}) + if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil { + t.Fatal(err) + } + } + // Identité qui ne concorde pas (PID recyclé) : on ne tue rien. + write(tuneProc{PID: cmd.Process.Pid, Start: start + "x", Exes: []string{exe}}) + tuneReapStale() + select { + case <-done: + t.Fatal("processus tué sur la foi du seul PID") + case <-time.After(300 * time.Millisecond): + } + if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) { + t.Fatal("verrou périmé non retiré") + } + // Identité complète : arrêté. + write(tuneProc{PID: cmd.Process.Pid, Start: start, Exes: []string{exe}}) + if err := tuneGuard(); err != nil { + t.Fatalf("verrou périmé bloquant : %v", err) + } + tuneReapStale() + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("essai orphelin toujours en vie") + } +} + +func TestTuneStageWanted(t *testing.T) { + for _, c := range []struct { + name string + only []string + want bool + }{ + {"lots", nil, true}, + {"délestage", []string{"delestage"}, true}, + {"files CUDA", []string{"files"}, true}, + {"threads", []string{"lots"}, false}, + {"placement", []string{"aucune"}, true}, // sa propre case décide + {"opt-in", []string{"lots"}, true}, + {"marges", []string{"aucune"}, false}, + } { + if got := tuneStageWanted(c.name, c.only); got != c.want { + t.Errorf("%s / %v : %v", c.name, c.only, got) + } + } +} diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 7b87a8a..8c272e6 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -84,6 +84,10 @@ type Conversation struct { // benching : le verrou est tenu par un benchmark (llm_bench_job.go). Même // logique que la tâche : l'interface et les refus disent qui occupe le moteur. benching bool + // leaseWhy : le refus à donner tant que benching tient le verrou — + // errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le + // même verrou (backend_tune_job.go). + leaseWhy error // File d'attente des messages envoyés PENDANT une génération (AJEAN // 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière @@ -475,9 +479,12 @@ var ErrBusy = fmt.Errorf("génération en cours") // nomme la tâche et on dit comment reprendre la main. func (c *Conversation) busyReason() error { c.mu.Lock() - name, bench := c.runningTaskName, c.benching + name, bench, why := c.runningTaskName, c.benching, c.leaseWhy c.mu.Unlock() if bench { + if why != nil { + return why + } return errBenchBusy } if name == "" { @@ -497,6 +504,11 @@ var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger // MODÈLE en tête du message, mais rendues comme pastilles dans la bulle : le fil // doit montrer ce que l'utilisateur a écrit, pas la consigne qu'on ajoute pour lui. func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, temperature float64) error { + // Optimisation en cours (backend_tune_lock.go) : le vrai moteur est arrêté, + // « le modèle charge » serait faux — on dit pourquoi. + if err := tuneGuard(); err != nil { + return err + } if !healthCheck() { return errModelLoading } @@ -1049,6 +1061,9 @@ func (c *Conversation) ctxNowLocked(msgs []Message, local bool) int { // événements passent par le flux d'abonnement, donc tous les appareils voient la // progression. Renvoie ErrBusy si un tour est déjà en cours. func (c *Conversation) CompactNow() error { + if err := tuneGuard(); err != nil { + return err + } if !healthCheck() { return errModelLoading } @@ -1123,6 +1138,7 @@ func (c *Conversation) Reset() { c.epoch++ c.Generating = false c.benching = false // Stop a annulé le bench : le verrou est rendu ici + c.leaseWhy = nil c.cancel = nil c.queued = nil // les messages en attente visaient le fil qu'on vient de vider c.cond.Broadcast() diff --git a/internal/loki/chat_prewarm.go b/internal/loki/chat_prewarm.go index 3f3a242..15ab32d 100644 --- a/internal/loki/chat_prewarm.go +++ b/internal/loki/chat_prewarm.go @@ -375,7 +375,7 @@ func (c *Conversation) prewarm(why string) { return } } - if sideJobs.Load() > 0 || benchRunning() || prewarmNearMidnight(time.Now()) { + if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) { return } if !healthCheck() { diff --git a/internal/loki/code_jobs.go b/internal/loki/code_jobs.go index b568993..294748d 100644 --- a/internal/loki/code_jobs.go +++ b/internal/loki/code_jobs.go @@ -83,6 +83,12 @@ func toolBashBg(args map[string]any) string { if reason := dangerousCommand(command); reason != "" { return refusedCommandResult(reason) } + // Un serveur de dev ou un build lancé pendant une optimisation fausserait + // ses mesures (CPU, disque) — et un tour de modèle n'a de toute façon pas pu + // démarrer : ceci ne vise qu'un tour lancé juste avant. + if err := tuneGuard(); err != nil { + return "[erreur] " + err.Error() + } ws := agentCwd() logDir := filepath.Join(ws, ".loki", "jobs") if err := os.MkdirAll(logDir, 0o755); err != nil { @@ -171,6 +177,23 @@ func toolBashTail(args map[string]any) string { return out + "\n" + tailRunes(tail, toolMaxOutput) } +// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de +// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le +// disque que mesurent ses essais. +func bgJobsRunning() int { + jobsMu.Lock() + defer jobsMu.Unlock() + n := 0 + for _, j := range jobs { + j.mu.Lock() + if !j.done { + n++ + } + j.mu.Unlock() + } + return n +} + // stopConvJobs tue les jobs d'une discussion (suppression / vidage de la // discussion : ses fichiers partent, ses process aussi). func stopConvJobs(convID string) { diff --git a/internal/loki/llm_bench.go b/internal/loki/llm_bench.go index 0a5ee06..7212bc1 100644 --- a/internal/loki/llm_bench.go +++ b/internal/loki/llm_bench.go @@ -159,6 +159,9 @@ type benchOpts struct { Mode string Prompt int // jetons de la ligne courte Predict int // jetons générés par la ligne courte + // Depth : profondeur imposée — l'optimiseur (backend_tune_run.go) compare + // ses essais à la MÊME profondeur ; 0 = choisie selon le contexte. + Depth int } func (o benchOpts) full() bool { return o.Mode == benchModeFull } @@ -675,6 +678,9 @@ func benchRun(ctx context.Context, e benchEngine, opts benchOpts, s benchSetup, // 3. La profondeur. d, why := benchDepthFor(nCtx, s.cpuPlaced, warm) + if opts.Depth > 0 { + d, why = opts.Depth, "" + } depth := &benchDepth{Ctx: nCtx, Target: d, Reuse: -1} res.Depth = depth if why != "" { @@ -822,6 +828,9 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben if externalActive() { return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe") } + if err := tuneGuard(); err != nil { + return nil, err + } port := LLMPort() if !healthCheck() { return nil, fmt.Errorf("serveur injoignable sur :%d", port) diff --git a/internal/loki/llm_bench_job.go b/internal/loki/llm_bench_job.go index 0779fc3..09666ae 100644 --- a/internal/loki/llm_bench_job.go +++ b/internal/loki/llm_bench_job.go @@ -38,18 +38,25 @@ var errBenchBusy = errors.New("un benchmark occupe le moteur — attends sa fin // qui a pu démarrer depuis ne doit pas être déclaré libre. L'epoch ne suffit // pas : changer de discussion le bumpe SANS arrêter le bench (loadFrom). func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) { + return c.measureLease(cancel, errBenchBusy) +} + +// measureLease : le verrou de génération pour une mesure du moteur — bench, +// ou optimiseur (backend_tune_job.go) — why étant le refus que reçoivent les +// autres pendant ce temps. +func (c *Conversation) measureLease(cancel context.CancelFunc, why error) (func(), error) { c.mu.Lock() defer c.mu.Unlock() if c.Generating { return nil, ErrBusy } - c.Generating, c.benching, c.cancel = true, true, cancel + c.Generating, c.benching, c.cancel, c.leaseWhy = true, true, cancel, why var once sync.Once return func() { once.Do(func() { c.mu.Lock() if c.benching { - c.Generating, c.benching, c.cancel = false, false, nil + c.Generating, c.benching, c.cancel, c.leaseWhy = false, false, nil, nil } c.mu.Unlock() }) @@ -92,6 +99,9 @@ func benchStart(opts benchOpts) (id string, code int, err error) { if externalActive() { return "", http.StatusBadRequest, errors.New("benchmark indisponible : le preset actif est une API externe") } + if err := tuneGuard(); err != nil { + return "", http.StatusConflict, err + } if !healthCheck() { return "", http.StatusServiceUnavailable, errModelLoading } diff --git a/internal/loki/llm_oai.go b/internal/loki/llm_oai.go index dd8f2e2..c1362c3 100644 --- a/internal/loki/llm_oai.go +++ b/internal/loki/llm_oai.go @@ -136,6 +136,13 @@ func oaiHandler() http.Handler { "benchmark en cours sur ce moteur — réessaie dans un moment", "server_error", "benchmark_running") return } + // Optimisation : le vrai moteur est arrêté, un moteur d'essai tient la carte. + if tuneGuard() != nil { + w.Header().Set("Retry-After", "60") + sendOAIError(w, http.StatusServiceUnavailable, + "optimisation du moteur en cours — réessaie dans quelques minutes", "server_error", "tuning_running") + return + } perfNoteForeign() } lp.ServeHTTP(w, r) diff --git a/internal/loki/run.go b/internal/loki/run.go index 8881ed0..b422803 100644 --- a/internal/loki/run.go +++ b/internal/loki/run.go @@ -96,6 +96,8 @@ func Main() { mustExit(cmdTest(args)) case "bench": mustExit(cmdBench(args)) + case "tune": + mustExit(cmdTune(args)) case "llamacpp": mustExit(cmdLlamacpp(args)) case "update": @@ -136,6 +138,14 @@ Moteur (loki-engine) : edit éditer la configuration dans $EDITOR switch [N] activer un preset de presets/ (interactif ou par numéro) test | bench [N] [--full] vérifier que l'IA répond / mesurer prefill + decode (--full : en profondeur) + tune [--placement] [--opt-in] [--budget MIN] [--stages lots,threads…] + optimiseur sans perte : essais (lots, threads, marges --fit…) + sur un moteur privé, mesurés au bench complet ; rien n'est + écrit sans ta réponse (copie du preset ou preset actuel, + vérifié puis rétabli en cas d'échec). --placement : --fit à la + place des experts / --tensor-split placés à la main (réécrit + EXTRA_ARGS) ; --opt-in : SPEC, CUDA_GRAPH_OPT, --backend-sampling + --stages : marges,lots,delestage,threads,files (défaut : toutes) vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer diff --git a/internal/loki/sys_procid_linux.go b/internal/loki/sys_procid_linux.go new file mode 100644 index 0000000..cba8cb0 --- /dev/null +++ b/internal/loki/sys_procid_linux.go @@ -0,0 +1,38 @@ +//go:build linux + +package loki + +import ( + "os" + "strconv" + "strings" +) + +// procIdentity : instant de démarrage (en tops d'horloge depuis le boot, champ +// 22 de /proc//stat) et binaire d'un processus vivant. Un PID recyclé +// après un redémarrage de Loki a un autre instant de démarrage : l'optimiseur ne +// tue un essai orphelin que si les deux concordent (backend_tune_lock.go). +func procIdentity(pid int) (start, exe string, ok bool) { + if pid <= 0 { + return "", "", false + } + b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return "", "", false + } + // Le nom du processus (champ 2, entre parenthèses) peut contenir des blancs + // et des parenthèses : on repart de la DERNIÈRE parenthèse fermante. + s := string(b) + i := strings.LastIndexByte(s, ')') + if i < 0 { + return "", "", false + } + f := strings.Fields(s[i+1:]) + // f[0] est le champ 3 (état) : le champ 22 est donc f[19]. Un zombie a fini + // de tourner, il ne compte plus comme vivant. + if len(f) < 20 || f[0] == "Z" { + return "", "", false + } + exe, _ = os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe") + return f[19], exe, true +} diff --git a/internal/loki/sys_procid_other.go b/internal/loki/sys_procid_other.go new file mode 100644 index 0000000..15f2d09 --- /dev/null +++ b/internal/loki/sys_procid_other.go @@ -0,0 +1,30 @@ +//go:build !linux && !windows + +package loki + +import ( + "os/exec" + "strconv" + "strings" +) + +// procIdentity : instant de démarrage et binaire d'un processus vivant, lus par +// ps (macOS, BSD). Voir la version Linux pour le pourquoi. +func procIdentity(pid int) (start, exe string, ok bool) { + if pid <= 0 || !processAlive(pid) { + return "", "", false + } + p := strconv.Itoa(pid) + out, err := exec.Command("ps", "-o", "lstart=", "-p", p).Output() + if err != nil { + return "", "", false + } + start = strings.Join(strings.Fields(string(out)), " ") + if start == "" { + return "", "", false + } + if out, err := exec.Command("ps", "-o", "comm=", "-p", p).Output(); err == nil { + exe = strings.TrimSpace(string(out)) + } + return start, exe, true +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 4da922c..54b03ca 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -17,6 +17,23 @@ import ( // // editConfig and showVram live here because they work the same everywhere. +// serviceAction : le seul passage vers serviceActionOS pour le reste de Loki. +// Pendant une optimisation (backend_tune_lock.go), le moteur d'essai tient la +// VRAM : démarrer le vrai moteur à côté — bascule de preset, GPU, mise à jour, +// rechargement… — chargerait deux modèles. Refus, quel que soit l'appelant et la +// plateforme ; l'optimiseur, lui, appelle serviceActionOS directement. Un verrou +// périmé (optimisation tuée) est écarté AVANT tout démarrage, son essai +// orphelin arrêté : sinon le moteur relancé partagerait la carte avec lui. +func serviceAction(action string) error { + if action == "start" || action == "restart" { + if err := tuneGuard(); err != nil { + return err + } + tuneReapStale() + } + return serviceActionOS(action) +} + // preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de // lancer le service. Sinon llama-server sortait en erreur, systemd le relançait // toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant diff --git a/internal/loki/sys_service_darwin.go b/internal/loki/sys_service_darwin.go index 47c11f5..3c51dc7 100644 --- a/internal/loki/sys_service_darwin.go +++ b/internal/loki/sys_service_darwin.go @@ -48,9 +48,9 @@ func launchdPlistPath(svc string) string { // en écriture par l'utilisateur du service après le chown de l'installation). func launchdLogPath() string { return filepath.Join(LokiHome(), serviceName()+".log") } -// serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w` +// serviceActionOS mappe start/stop/restart/enable/disable sur launchctl. `load -w` // (re)active le service ET le rend persistant au boot ; `unload -w` le désactive. -func serviceAction(action string) error { +func serviceActionOS(action string) error { svc := serviceName() plist := launchdPlistPath(svc) // Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où Loki tourne diff --git a/internal/loki/sys_service_linux.go b/internal/loki/sys_service_linux.go index 11db409..cb848c1 100644 --- a/internal/loki/sys_service_linux.go +++ b/internal/loki/sys_service_linux.go @@ -11,11 +11,11 @@ import ( "time" ) -// serviceAction wraps `systemctl ` with passwordless sudo where +// serviceActionOS wraps `systemctl ` with passwordless sudo where // it makes sense, and prints a follow-up status check after start/restart. // Sans systemd (conteneur Docker, LOKI_CONTAINER=1), on bascule sur la // supervision par fichier PID (sys_service_container.go). -func serviceAction(action string) error { +func serviceActionOS(action string) error { if !systemdAvailable() { return userSvcAction(action) } diff --git a/internal/loki/sys_service_windows.go b/internal/loki/sys_service_windows.go index 12906c2..0272219 100644 --- a/internal/loki/sys_service_windows.go +++ b/internal/loki/sys_service_windows.go @@ -32,7 +32,7 @@ func logFilePath() string { return filepath.Join(LokiHome(), serviceName()+".log // systemdAvailable : jamais de systemd sous Windows (consulté par relay_link.go). func systemdAvailable() bool { return false } -func serviceAction(action string) error { +func serviceActionOS(action string) error { switch action { case "start": return svcStart() diff --git a/internal/loki/sys_tune_unix.go b/internal/loki/sys_tune_unix.go new file mode 100644 index 0000000..22f0989 --- /dev/null +++ b/internal/loki/sys_tune_unix.go @@ -0,0 +1,55 @@ +//go:build unix + +package loki + +import ( + "os/exec" + "syscall" + "time" +) + +// Moteur d'essai de l'optimiseur (backend_tune_run.go), côté Unix. +// +// L'essai est un « loki serve » qui devient llama-server par exec : même PID, +// même groupe. Setpgid en fait le chef d'un groupe à lui — un signal au groupe +// (-pid) l'atteint, quoi qu'il ait lancé, et rien d'autre : ni le processus web +// qui l'a démarré, ni le vrai moteur. +func tuneTrialAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// tuneKillTree arrête le groupe de l'essai : SIGTERM, puis SIGKILL s'il traîne. +// done se ferme quand l'appelant a récolté le processus (cmd.Wait) ; nil pour un +// orphelin d'un ancien Loki, dont on ne peut que sonder l'existence — un enfant +// non récolté resterait « vivant » (zombie) aux yeux du signal 0. +func tuneKillTree(pid int, done <-chan struct{}) { + if pid <= 0 { + return + } + gone := func(wait time.Duration) bool { + deadline := time.Now().Add(wait) + for time.Now().Before(deadline) { + if done != nil { + select { + case <-done: + return true + case <-time.After(100 * time.Millisecond): + } + continue + } + if !processAlive(pid) { + return true + } + time.Sleep(100 * time.Millisecond) + } + return false + } + if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil { + _ = syscall.Kill(pid, syscall.SIGTERM) + } + if gone(10 * time.Second) { + return + } + _ = syscall.Kill(-pid, syscall.SIGKILL) + gone(5 * time.Second) +} diff --git a/internal/loki/sys_tune_windows.go b/internal/loki/sys_tune_windows.go new file mode 100644 index 0000000..da8708d --- /dev/null +++ b/internal/loki/sys_tune_windows.go @@ -0,0 +1,78 @@ +//go:build windows + +package loki + +import ( + "os/exec" + "strconv" + "syscall" + "time" + "unsafe" +) + +// Moteur d'essai de l'optimiseur (backend_tune_run.go), côté Windows. +// +// L'essai est un « loki serve » qui lance llama-server en ENFANT (execServer) : +// deux processus. taskkill /T emporte l'arbre entier à partir du PID vérifié, +// comme svcStop le fait pour le vrai moteur. Pas de console : le processus web +// tourne souvent en mode application. +func tuneTrialAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: createNewProcessGroup | createNoWindow, + } +} + +// tuneKillTree arrête l'arbre de l'essai. done : voir la version Unix. +func tuneKillTree(pid int, done <-chan struct{}) { + if pid <= 0 { + return + } + _ = hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")).Run() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if done != nil { + select { + case <-done: + return + case <-time.After(100 * time.Millisecond): + } + continue + } + if !processAlive(pid) { + return + } + time.Sleep(100 * time.Millisecond) + } +} + +// procIdentity : heure de création et image d'un processus vivant. Le PID seul +// ne suffit pas à désigner l'essai orphelin d'un Loki redémarré : Windows +// recycle vite ses PID, et tuer « le PID noté » pourrait viser n'importe quoi. +func procIdentity(pid int) (start, exe string, ok bool) { + if pid <= 0 || !processAlive(pid) { + return "", "", false + } + const queryLimitedInfo = 0x1000 + k := syscall.NewLazyDLL("kernel32.dll") + h, _, _ := k.NewProc("OpenProcess").Call(queryLimitedInfo, 0, uintptr(pid)) + if h == 0 { + return "", "", false + } + defer k.NewProc("CloseHandle").Call(h) + var creation, exit, kernel, user syscall.Filetime + r, _, _ := k.NewProc("GetProcessTimes").Call(h, + uintptr(unsafe.Pointer(&creation)), uintptr(unsafe.Pointer(&exit)), + uintptr(unsafe.Pointer(&kernel)), uintptr(unsafe.Pointer(&user))) + if r == 0 { + return "", "", false + } + start = strconv.FormatInt(creation.Nanoseconds(), 10) + buf := make([]uint16, 1024) + size := uint32(len(buf)) + if r, _, _ := k.NewProc("QueryFullProcessImageNameW").Call(h, 0, + uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(&size))); r != 0 { + exe = syscall.UTF16ToString(buf[:size]) + } + return start, exe, true +} diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 67529dc..771f780 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -30,6 +30,9 @@ import ( // lastReport est le compte-rendu du passage précédent (vide au premier) : il est // réinjecté pour donner à l'IA une continuité d'une exécution à l'autre. func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) { + if err := tuneGuard(); err != nil { + return "", err + } if !healthCheck() { return "", errModelLoading } diff --git a/internal/loki/tasks_sched.go b/internal/loki/tasks_sched.go index 2b9ae7f..e4027ac 100644 --- a/internal/loki/tasks_sched.go +++ b/internal/loki/tasks_sched.go @@ -49,7 +49,8 @@ func primeNextRuns() { // toute façon impossible (gate de génération), et étaler leur départ évite qu'une // rafale de tâches monopolise le modèle d'un coup. func tickTasks(now time.Time) { - if tasksPaused() { + if tasksPaused() || tuneGuard() != nil { + // Optimisation en cours : la tâche due attend sa fin, elle n'échoue pas. return } nowMs := now.UnixMilli() diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 72d984a..84080ab 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3576,6 +3576,12 @@ html[data-files="1"] #files-btn{color:var(--accent)} + +
+ Optimiseurlots, threads, marges --fit… mesurés sur un moteur privé · rien n'est écrit sans ton accord + +
@@ -3643,6 +3649,24 @@ html[data-files="1"] #files-btn{color:var(--accent)} + + diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index f595a12..1f4ff40 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -1350,6 +1350,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid + +
+ Optimiseurlots, threads, marges --fit… mesurés sur un moteur privé · rien n'est écrit sans ton accord + +
@@ -1417,6 +1423,24 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid + +