mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Optimiseur : loki tune et bouton « Optimiser », essais sans perte sur un moteur privé
Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset demandait de dupliquer, basculer et comparer des bench complets un par un. L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni à ce que calcule le modèle, et n'écrit rien sans un clic. - Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur 127.0.0.1 et un port libre, groupe de processus tué en sortie - Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage + binaire) vérifié par serviceAction start/restart et par le chat, les tâches, bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur - Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande - EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de chaque essai composée à blanc et comparée à la référence (« dénature »), contexte, slots et cache KV recontrôlés une fois chargé - Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages) - Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit clé par clé, vérifié par une sonde et rétabli en cas d'échec Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f87c7b615b
commit
9bffff78c2
36 files changed
+4580
-16
No files matched your search
@@ -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/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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()
|
||||
}
|
||||
@@ -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/<pid>/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")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// serviceAction wraps `systemctl <action> <svc>` with passwordless sudo where
|
||||
// serviceActionOS wraps `systemctl <action> <svc>` 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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3576,6 +3576,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<div class="pe-row stack" id="s-loadmode-warn-row" style="display:none">
|
||||
<div class="pe-note" id="s-loadmode-warn" style="color:var(--warn)"></div>
|
||||
</div>
|
||||
<!-- Optimiseur sans perte (backend_tune*.go) : essais sur un moteur
|
||||
privé, mesurés au bench complet. Rien n'est écrit sans un clic. -->
|
||||
<div class="pe-row" id="m-tune-row">
|
||||
<span class="pe-row-l">Optimiseur<span class="pe-sub" id="m-tune-sub">lots, threads, marges --fit… mesurés sur un moteur privé · rien n'est écrit sans ton accord</span></span>
|
||||
<span class="pe-row-c"><button class="pe-link" onclick="openTuneModal()">Optimiser…</button></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3643,6 +3649,24 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Optimiseur sans perte (29-tune.js) : au-dessus de l'éditeur de preset, d'où
|
||||
il s'ouvre. Les options cochées ici ne s'enregistrent nulle part. -->
|
||||
<div id="tune-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:40;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeTuneModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:640px;display:flex;flex-direction:column;max-height:90vh">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>Optimiser le preset</strong>
|
||||
<button onclick="closeTuneModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div id="tune-body" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;overflow:auto"></div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap">
|
||||
<button id="tune-cancel" onclick="cancelTuneUI()" style="display:none">annuler l'optimisation</button>
|
||||
<button id="tune-copy" onclick="applyTuneUI('copy')" style="display:none">enregistrer dans une copie</button>
|
||||
<button id="tune-apply" onclick="applyTuneUI('preset')" style="display:none;border-color:var(--accent);color:var(--accent)">appliquer à ce preset…</button>
|
||||
<button id="tune-start" onclick="startTuneUI()" style="border-color:var(--accent);color:var(--accent)">lancer</button>
|
||||
<button onclick="closeTuneModal()">fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="marked.min.js"></script>
|
||||
<script>
|
||||
// marked: GFM on, no auto-breaks (single newlines stay inline).
|
||||
@@ -11900,4 +11924,181 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
i18nApply(document.body);
|
||||
i18nWatch();
|
||||
});
|
||||
// Optimiseur sans perte (backend_tune*.go). Il tourne côté serveur, en tâche de
|
||||
// fond : la modale lance, suit, montre les mesures — et n'écrit RIEN sans un
|
||||
// clic (copie du preset, ou ce preset après confirmation du diff).
|
||||
let tunePoll = null, tuneState = 'form';
|
||||
// Étapes de base (tuneStages côté serveur) ; placement et opt-in ont leur case.
|
||||
const TUNE_STAGES = [['lots','micro-lot et lot'], ['threads','threads'], ['delestage','délestage'], ['marges','marges --fit'], ['files','files CUDA']];
|
||||
function closeTuneModal(){ clearTimeout(tunePoll); hideModal('tune-modal'); }
|
||||
function tuneBtns(state){
|
||||
tuneState = state;
|
||||
const show = (id, on) => { const b = document.getElementById(id); if(b) b.style.display = on ? '' : 'none'; };
|
||||
show('tune-start', state !== 'running');
|
||||
const sb = document.getElementById('tune-start');
|
||||
if(sb) sb.textContent = state === 'form' ? 'lancer' : 'nouvelle mesure…';
|
||||
show('tune-cancel', state === 'running');
|
||||
show('tune-copy', state === 'result');
|
||||
show('tune-apply', state === 'result');
|
||||
}
|
||||
function tuneSec(s){
|
||||
s = Math.max(0, Math.round(s||0));
|
||||
return s >= 60 ? Math.floor(s/60) + ' min ' + String(s%60).padStart(2,'0') + ' s' : s + ' s';
|
||||
}
|
||||
async function openTuneModal(){
|
||||
showModal('tune-modal');
|
||||
const body = document.getElementById('tune-body');
|
||||
body.innerHTML = '<div class="muted">…</div>';
|
||||
let st = {};
|
||||
try{ st = await jget('/api/tune/status'); }catch(_){}
|
||||
// Une mesure finie (modale fermée pendant qu'elle tournait) : son résultat
|
||||
// d'abord, avec ses boutons — « nouvelle mesure… » ramène au formulaire.
|
||||
if(st.running || st.applying || st.apply || (st.result && st.result.preset_id === editingKey)){ tuneWatch(); return; }
|
||||
let last = null;
|
||||
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
|
||||
tuneForm(last && last.ok ? last : null, st.elsewhere);
|
||||
}
|
||||
function tuneForm(last, elsewhere){
|
||||
const body = document.getElementById('tune-body');
|
||||
let h = '<div class="pe-note">Des essais sur un moteur <b>privé</b> (127.0.0.1), chacun mesuré au bench complet : '
|
||||
+ 'micro-lot et lot, threads (poids sur CPU), seuil de délestage, marges <code>--fit</code> et files CUDA (2 cartes ou plus). '
|
||||
+ 'Jamais touchés : modèle, contexte, cache KV, raisonnement, échantillonnage, slots. Le moteur principal est arrêté pendant la mesure '
|
||||
+ '(chat et tâches en pause), puis relancé. Rien n\'est écrit sans ton accord.</div>'
|
||||
+ '<div class="pe-note muted">Mesure la version <b>enregistrée</b> du preset en service.</div>'
|
||||
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:12px"><span class="muted">Étapes :</span>'
|
||||
+ TUNE_STAGES.map(s => '<label style="display:flex;gap:4px;align-items:center"><input type="checkbox" class="tune-stage" value="' + s[0] + '" checked> ' + s[1] + '</label>').join('')
|
||||
+ '</div>'
|
||||
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-placement"> <span>Inclure le placement'
|
||||
+ '<span class="muted" style="display:block;font-size:11px">--fit au lieu des experts MoE ou du --tensor-split placés à la main : réécrit EXTRA_ARGS, confirmé à part</span></span></label>'
|
||||
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-optin"> <span>Inclure les options opt-in'
|
||||
+ '<span class="muted" style="display:block;font-size:11px">SPEC (sortie inchangée, jugée sur prose et code), SPEC_N_MAX, CUDA_GRAPH_OPT (expérimental), --backend-sampling</span></span></label>'
|
||||
+ '<label style="display:flex;gap:8px;align-items:center">Budget <input type="number" id="tune-budget" class="pe-val" min="5" max="360" step="5" value="30" style="width:80px"> minutes'
|
||||
+ '<span class="muted" style="font-size:11px">— au-delà, résultat partiel</span></label>';
|
||||
if(elsewhere) h += '<div class="pe-note" style="color:var(--warn)">Une optimisation tourne déjà (' + escHtml(elsewhere) + ').</div>';
|
||||
if(last && last.result){
|
||||
const r = last.result;
|
||||
const when = new Date(r.at*1000).toLocaleString();
|
||||
h += '<div class="pe-note">Dernière optimisation : ' + escHtml(when) + (r.best ? ' — ' + escHtml(r.best) + ', −' + (r.gain*100).toFixed(1) + ' % par tour' : ' — rien de mieux trouvé') + '</div>';
|
||||
if(last.engine_changed) h += '<div class="pe-note" style="color:var(--warn)">Le moteur a changé depuis (' + escHtml(last.engine_changed) + ') : les meilleurs réglages ont pu bouger — relance l\'optimisation.</div>';
|
||||
if(last.preset_changed) h += '<div class="pe-note muted">Le preset a été modifié depuis cette mesure.</div>';
|
||||
}
|
||||
body.innerHTML = h;
|
||||
tuneBtns('form');
|
||||
}
|
||||
async function startTuneUI(){
|
||||
if(tuneState !== 'form'){
|
||||
let last = null;
|
||||
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
|
||||
tuneForm(last && last.ok ? last : null);
|
||||
return;
|
||||
}
|
||||
const placement = !!(document.getElementById('tune-placement')||{}).checked;
|
||||
const optin = !!(document.getElementById('tune-optin')||{}).checked;
|
||||
const budget = parseInt((document.getElementById('tune-budget')||{}).value, 10) || 30;
|
||||
// Toutes cochées = toutes (liste vide) ; aucune = seulement placement / opt-in.
|
||||
const boxes = [...document.querySelectorAll('.tune-stage')];
|
||||
const picked = boxes.filter(b => b.checked).map(b => b.value);
|
||||
const stages = picked.length === boxes.length ? [] : (picked.length ? picked : ['aucune']);
|
||||
const msg = 'Le moteur principal va être ARRÊTÉ pendant jusqu\'à ' + budget + ' minutes : chat, tâches et clients /v1 attendent. '
|
||||
+ 'Chaque essai recharge le modèle. Lancer l\'optimisation ?';
|
||||
if(!await askConfirm(msg, {title:'Optimiser', okText:'Lancer'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/tune', {placement, optin, budget_min: budget, stages}); }catch(e){ r = {ok:false, error:e.message}; }
|
||||
if(!r.ok){ document.getElementById('tune-body').insertAdjacentHTML('beforeend', '<div style="color:var(--err)">' + escHtml(r.error||'?') + '</div>'); return; }
|
||||
tuneWatch();
|
||||
}
|
||||
async function cancelTuneUI(){ try{ await jpost('/api/tune/cancel', {}); }catch(_){} }
|
||||
function tuneTrialsHTML(trials){
|
||||
if(!trials || !trials.length) return '';
|
||||
let h = '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr class="muted"><td>étape</td><td>essai</td>'
|
||||
+ '<td style="text-align:right">tour</td><td style="text-align:right">VRAM libre</td><td>statut</td></tr>';
|
||||
for(const t of trials){
|
||||
const color = t.status === 'retenu' ? 'var(--ok)' : (t.status === 'échec' || t.status === 'écarté') ? 'var(--warn)' : 'inherit';
|
||||
const runs = (t.runs||[]);
|
||||
let tip = '';
|
||||
if(runs.length){
|
||||
const r0 = runs[0];
|
||||
tip = 'prose ' + (r0.prose_pp||0).toFixed(0) + ' / ' + (r0.prose_tg||0).toFixed(1) + ' t/s';
|
||||
if(r0.depth) tip += ' · à ' + r0.depth + ' jetons : froid ' + (r0.cold_pp||0).toFixed(0) + ', tours ' + (r0.cached_pp||0).toFixed(0) + ', decode ' + (r0.tg||0).toFixed(1) + ' t/s';
|
||||
tip += ' · ' + runs.length + ' passage(s)';
|
||||
}
|
||||
if(t.offloaded) tip += ' · couches ' + t.offloaded;
|
||||
h += '<tr title="' + escHtml(tip) + '"><td class="muted" style="padding:3px 6px 3px 0">' + escHtml(t.stage) + '</td>'
|
||||
+ '<td>' + escHtml(t.label) + '</td>'
|
||||
+ '<td style="text-align:right">' + (t.turn_sec ? t.turn_sec.toFixed(1) + ' s' : '—') + '</td>'
|
||||
+ '<td style="text-align:right">' + (t.headroom_mib >= 0 ? t.headroom_mib + ' Mio' : '—') + '</td>'
|
||||
+ '<td style="color:' + color + ';padding-left:6px">' + escHtml(t.status) + (t.why ? ' <span class="muted">· ' + escHtml(t.why) + '</span>' : '') + '</td></tr>';
|
||||
}
|
||||
return h + '</table>';
|
||||
}
|
||||
function tuneResultHTML(r){
|
||||
let h = '<div class="pe-note muted">Score : durée d\'un tour type — ' + (r.weights.k).toFixed(0) + ' jetons lus (cache repris), '
|
||||
+ (r.weights.g).toFixed(0) + ' écrits, ' + (r.weights.f_cold*100).toFixed(0) + ' % des tours relus à froid à ' + r.depth + ' jetons ('
|
||||
+ escHtml(r.weights.from) + '). Un gain compte au-delà de 3 % et de l\'écart entre passages. Moteur ' + escHtml(r.engine||'?') + '.</div>';
|
||||
h += tuneTrialsHTML([r.baseline].concat(r.trials||[]));
|
||||
for(const s of (r.skipped||[])) h += '<div class="muted" style="font-size:11px">sauté — ' + escHtml(s) + '</div>';
|
||||
for(const n of (r.notes||[])) h += '<div style="font-size:11px;color:var(--warn)">' + escHtml(n) + '</div>';
|
||||
if(r.partial) h += '<div style="font-size:11px;color:var(--warn)">résultat partiel : ' + escHtml(r.partial) + '</div>';
|
||||
if(r.best){
|
||||
h += '<div style="margin-top:6px"><b style="color:var(--ok)">Meilleur : ' + escHtml(r.best) + '</b> — −' + (r.gain*100).toFixed(1) + ' % par tour</div>'
|
||||
+ '<div class="pe-note" style="white-space:pre-wrap;font-family:var(--mono,monospace);font-size:11px">' + escHtml((r.changes||[]).join('\n')) + '</div>';
|
||||
if(r.placement) h += '<div class="pe-note" style="color:var(--warn)">Ce résultat réécrit EXTRA_ARGS (placement) : à confirmer à part.</div>';
|
||||
} else {
|
||||
h += '<div style="margin-top:6px">La configuration actuelle reste la meilleure : rien à changer.</div>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function tuneWatch(){
|
||||
clearTimeout(tunePoll);
|
||||
const body = document.getElementById('tune-body');
|
||||
const tick = async () => {
|
||||
let st;
|
||||
try{ st = await jget('/api/tune/status'); }
|
||||
catch(_){ tunePoll = setTimeout(tick, 3000); return; }
|
||||
if(st.running){
|
||||
tuneBtns('running');
|
||||
const eta = st.eta_sec >= 0 ? ' · reste ~' + tuneSec(st.eta_sec) : '';
|
||||
body.innerHTML = '<div class="muted" style="text-align:center">⏳ ' + escHtml(st.phase||'…') + '<br>' + tuneSec(st.elapsed_sec) + eta
|
||||
+ '<br><span style="font-size:11px">moteur principal arrêté · chat et tâches en pause</span></div>' + tuneTrialsHTML(st.trials);
|
||||
tunePoll = setTimeout(tick, 2000);
|
||||
return;
|
||||
}
|
||||
let h = '';
|
||||
if(st.error) h += '<div style="color:var(--err)">' + escHtml(st.error) + '</div>';
|
||||
if(st.result) h += tuneResultHTML(st.result);
|
||||
const a = st.apply;
|
||||
if(a){
|
||||
h += '<div class="pe-note">' + (a.log||[]).map(escHtml).join('<br>') + '</div>';
|
||||
if(a.message) h += '<div style="color:var(--ok)">' + escHtml(a.message) + '</div>';
|
||||
if(a.error) h += '<div style="color:var(--err)">' + escHtml(a.error) + '</div>';
|
||||
}
|
||||
body.innerHTML = h || '<div class="muted">rien à montrer</div>';
|
||||
const canApply = !!(st.result && st.result.best) && !st.applying && !(a && a.message);
|
||||
tuneBtns(canApply ? 'result' : 'done');
|
||||
if(st.applying){ tunePoll = setTimeout(tick, 2000); }
|
||||
};
|
||||
tick();
|
||||
}
|
||||
async function applyTuneUI(target){
|
||||
let st = {};
|
||||
try{ st = await jget('/api/tune/status'); }catch(_){}
|
||||
const r = st.result;
|
||||
if(!r || !r.best) return;
|
||||
const diff = (r.changes||[]).map(c => '• ' + c).join('\n');
|
||||
if(target === 'copy'){
|
||||
if(!await askConfirm('Créer une COPIE de « ' + r.preset_name + ' » avec :\n\n' + diff + '\n\nLe preset d\'origine ne bouge pas.',
|
||||
{title:'Enregistrer dans une copie', okText:'Créer la copie'})) return;
|
||||
} else {
|
||||
if(!await askConfirm('Réécrire « ' + r.preset_name + ' » (sauvegardé avant) avec :\n\n' + diff
|
||||
+ '\n\nLe moteur redémarre, puis une sonde vérifie un prompt à ' + r.depth + ' jetons ; en cas d\'échec, l\'ancienne version est rétablie.',
|
||||
{title:'Appliquer à ce preset', okText:'Appliquer'})) return;
|
||||
if(r.placement && !await askConfirm('Ce résultat RÉÉCRIT EXTRA_ARGS (placement des experts ou --tensor-split retirés pour --fit). Confirmer ?',
|
||||
{title:'Placement', okText:'Confirmer'})) return;
|
||||
}
|
||||
let res;
|
||||
try{ res = await jpost('/api/tune/apply', {target}); }catch(e){ res = {ok:false, error:e.message}; }
|
||||
if(!res.ok){ toast('erreur : ' + (res.error||'')); return; }
|
||||
if(target === 'copy'){ toast(res.message || 'copie créée'); KINDS.preset.reload(); return; }
|
||||
tuneWatch();
|
||||
}
|
||||
</script></body></html>
|
||||
@@ -1350,6 +1350,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div class="pe-row stack" id="s-loadmode-warn-row" style="display:none">
|
||||
<div class="pe-note" id="s-loadmode-warn" style="color:var(--warn)"></div>
|
||||
</div>
|
||||
<!-- Optimiseur sans perte (backend_tune*.go) : essais sur un moteur
|
||||
privé, mesurés au bench complet. Rien n'est écrit sans un clic. -->
|
||||
<div class="pe-row" id="m-tune-row">
|
||||
<span class="pe-row-l">Optimiseur<span class="pe-sub" id="m-tune-sub">lots, threads, marges --fit… mesurés sur un moteur privé · rien n'est écrit sans ton accord</span></span>
|
||||
<span class="pe-row-c"><button class="pe-link" onclick="openTuneModal()">Optimiser…</button></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1417,6 +1423,24 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Optimiseur sans perte (29-tune.js) : au-dessus de l'éditeur de preset, d'où
|
||||
il s'ouvre. Les options cochées ici ne s'enregistrent nulle part. -->
|
||||
<div id="tune-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:40;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeTuneModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:640px;display:flex;flex-direction:column;max-height:90vh">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>Optimiser le preset</strong>
|
||||
<button onclick="closeTuneModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div id="tune-body" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;overflow:auto"></div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap">
|
||||
<button id="tune-cancel" onclick="cancelTuneUI()" style="display:none">annuler l'optimisation</button>
|
||||
<button id="tune-copy" onclick="applyTuneUI('copy')" style="display:none">enregistrer dans une copie</button>
|
||||
<button id="tune-apply" onclick="applyTuneUI('preset')" style="display:none;border-color:var(--accent);color:var(--accent)">appliquer à ce preset…</button>
|
||||
<button id="tune-start" onclick="startTuneUI()" style="border-color:var(--accent);color:var(--accent)">lancer</button>
|
||||
<button onclick="closeTuneModal()">fermer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="marked.min.js"></script>
|
||||
<script>
|
||||
@@JS@@
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Optimiseur sans perte (backend_tune*.go). Il tourne côté serveur, en tâche de
|
||||
// fond : la modale lance, suit, montre les mesures — et n'écrit RIEN sans un
|
||||
// clic (copie du preset, ou ce preset après confirmation du diff).
|
||||
let tunePoll = null, tuneState = 'form';
|
||||
// Étapes de base (tuneStages côté serveur) ; placement et opt-in ont leur case.
|
||||
const TUNE_STAGES = [['lots','micro-lot et lot'], ['threads','threads'], ['delestage','délestage'], ['marges','marges --fit'], ['files','files CUDA']];
|
||||
function closeTuneModal(){ clearTimeout(tunePoll); hideModal('tune-modal'); }
|
||||
function tuneBtns(state){
|
||||
tuneState = state;
|
||||
const show = (id, on) => { const b = document.getElementById(id); if(b) b.style.display = on ? '' : 'none'; };
|
||||
show('tune-start', state !== 'running');
|
||||
const sb = document.getElementById('tune-start');
|
||||
if(sb) sb.textContent = state === 'form' ? 'lancer' : 'nouvelle mesure…';
|
||||
show('tune-cancel', state === 'running');
|
||||
show('tune-copy', state === 'result');
|
||||
show('tune-apply', state === 'result');
|
||||
}
|
||||
function tuneSec(s){
|
||||
s = Math.max(0, Math.round(s||0));
|
||||
return s >= 60 ? Math.floor(s/60) + ' min ' + String(s%60).padStart(2,'0') + ' s' : s + ' s';
|
||||
}
|
||||
async function openTuneModal(){
|
||||
showModal('tune-modal');
|
||||
const body = document.getElementById('tune-body');
|
||||
body.innerHTML = '<div class="muted">…</div>';
|
||||
let st = {};
|
||||
try{ st = await jget('/api/tune/status'); }catch(_){}
|
||||
// Une mesure finie (modale fermée pendant qu'elle tournait) : son résultat
|
||||
// d'abord, avec ses boutons — « nouvelle mesure… » ramène au formulaire.
|
||||
if(st.running || st.applying || st.apply || (st.result && st.result.preset_id === editingKey)){ tuneWatch(); return; }
|
||||
let last = null;
|
||||
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
|
||||
tuneForm(last && last.ok ? last : null, st.elsewhere);
|
||||
}
|
||||
function tuneForm(last, elsewhere){
|
||||
const body = document.getElementById('tune-body');
|
||||
let h = '<div class="pe-note">Des essais sur un moteur <b>privé</b> (127.0.0.1), chacun mesuré au bench complet : '
|
||||
+ 'micro-lot et lot, threads (poids sur CPU), seuil de délestage, marges <code>--fit</code> et files CUDA (2 cartes ou plus). '
|
||||
+ 'Jamais touchés : modèle, contexte, cache KV, raisonnement, échantillonnage, slots. Le moteur principal est arrêté pendant la mesure '
|
||||
+ '(chat et tâches en pause), puis relancé. Rien n\'est écrit sans ton accord.</div>'
|
||||
+ '<div class="pe-note muted">Mesure la version <b>enregistrée</b> du preset en service.</div>'
|
||||
+ '<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:12px"><span class="muted">Étapes :</span>'
|
||||
+ TUNE_STAGES.map(s => '<label style="display:flex;gap:4px;align-items:center"><input type="checkbox" class="tune-stage" value="' + s[0] + '" checked> ' + s[1] + '</label>').join('')
|
||||
+ '</div>'
|
||||
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-placement"> <span>Inclure le placement'
|
||||
+ '<span class="muted" style="display:block;font-size:11px">--fit au lieu des experts MoE ou du --tensor-split placés à la main : réécrit EXTRA_ARGS, confirmé à part</span></span></label>'
|
||||
+ '<label style="display:flex;gap:8px;align-items:flex-start"><input type="checkbox" id="tune-optin"> <span>Inclure les options opt-in'
|
||||
+ '<span class="muted" style="display:block;font-size:11px">SPEC (sortie inchangée, jugée sur prose et code), SPEC_N_MAX, CUDA_GRAPH_OPT (expérimental), --backend-sampling</span></span></label>'
|
||||
+ '<label style="display:flex;gap:8px;align-items:center">Budget <input type="number" id="tune-budget" class="pe-val" min="5" max="360" step="5" value="30" style="width:80px"> minutes'
|
||||
+ '<span class="muted" style="font-size:11px">— au-delà, résultat partiel</span></label>';
|
||||
if(elsewhere) h += '<div class="pe-note" style="color:var(--warn)">Une optimisation tourne déjà (' + escHtml(elsewhere) + ').</div>';
|
||||
if(last && last.result){
|
||||
const r = last.result;
|
||||
const when = new Date(r.at*1000).toLocaleString();
|
||||
h += '<div class="pe-note">Dernière optimisation : ' + escHtml(when) + (r.best ? ' — ' + escHtml(r.best) + ', −' + (r.gain*100).toFixed(1) + ' % par tour' : ' — rien de mieux trouvé') + '</div>';
|
||||
if(last.engine_changed) h += '<div class="pe-note" style="color:var(--warn)">Le moteur a changé depuis (' + escHtml(last.engine_changed) + ') : les meilleurs réglages ont pu bouger — relance l\'optimisation.</div>';
|
||||
if(last.preset_changed) h += '<div class="pe-note muted">Le preset a été modifié depuis cette mesure.</div>';
|
||||
}
|
||||
body.innerHTML = h;
|
||||
tuneBtns('form');
|
||||
}
|
||||
async function startTuneUI(){
|
||||
if(tuneState !== 'form'){
|
||||
let last = null;
|
||||
if(editingKey){ try{ last = await jget('/api/tune/last?id=' + encodeURIComponent(editingKey)); }catch(_){} }
|
||||
tuneForm(last && last.ok ? last : null);
|
||||
return;
|
||||
}
|
||||
const placement = !!(document.getElementById('tune-placement')||{}).checked;
|
||||
const optin = !!(document.getElementById('tune-optin')||{}).checked;
|
||||
const budget = parseInt((document.getElementById('tune-budget')||{}).value, 10) || 30;
|
||||
// Toutes cochées = toutes (liste vide) ; aucune = seulement placement / opt-in.
|
||||
const boxes = [...document.querySelectorAll('.tune-stage')];
|
||||
const picked = boxes.filter(b => b.checked).map(b => b.value);
|
||||
const stages = picked.length === boxes.length ? [] : (picked.length ? picked : ['aucune']);
|
||||
const msg = 'Le moteur principal va être ARRÊTÉ pendant jusqu\'à ' + budget + ' minutes : chat, tâches et clients /v1 attendent. '
|
||||
+ 'Chaque essai recharge le modèle. Lancer l\'optimisation ?';
|
||||
if(!await askConfirm(msg, {title:'Optimiser', okText:'Lancer'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/tune', {placement, optin, budget_min: budget, stages}); }catch(e){ r = {ok:false, error:e.message}; }
|
||||
if(!r.ok){ document.getElementById('tune-body').insertAdjacentHTML('beforeend', '<div style="color:var(--err)">' + escHtml(r.error||'?') + '</div>'); return; }
|
||||
tuneWatch();
|
||||
}
|
||||
async function cancelTuneUI(){ try{ await jpost('/api/tune/cancel', {}); }catch(_){} }
|
||||
function tuneTrialsHTML(trials){
|
||||
if(!trials || !trials.length) return '';
|
||||
let h = '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr class="muted"><td>étape</td><td>essai</td>'
|
||||
+ '<td style="text-align:right">tour</td><td style="text-align:right">VRAM libre</td><td>statut</td></tr>';
|
||||
for(const t of trials){
|
||||
const color = t.status === 'retenu' ? 'var(--ok)' : (t.status === 'échec' || t.status === 'écarté') ? 'var(--warn)' : 'inherit';
|
||||
const runs = (t.runs||[]);
|
||||
let tip = '';
|
||||
if(runs.length){
|
||||
const r0 = runs[0];
|
||||
tip = 'prose ' + (r0.prose_pp||0).toFixed(0) + ' / ' + (r0.prose_tg||0).toFixed(1) + ' t/s';
|
||||
if(r0.depth) tip += ' · à ' + r0.depth + ' jetons : froid ' + (r0.cold_pp||0).toFixed(0) + ', tours ' + (r0.cached_pp||0).toFixed(0) + ', decode ' + (r0.tg||0).toFixed(1) + ' t/s';
|
||||
tip += ' · ' + runs.length + ' passage(s)';
|
||||
}
|
||||
if(t.offloaded) tip += ' · couches ' + t.offloaded;
|
||||
h += '<tr title="' + escHtml(tip) + '"><td class="muted" style="padding:3px 6px 3px 0">' + escHtml(t.stage) + '</td>'
|
||||
+ '<td>' + escHtml(t.label) + '</td>'
|
||||
+ '<td style="text-align:right">' + (t.turn_sec ? t.turn_sec.toFixed(1) + ' s' : '—') + '</td>'
|
||||
+ '<td style="text-align:right">' + (t.headroom_mib >= 0 ? t.headroom_mib + ' Mio' : '—') + '</td>'
|
||||
+ '<td style="color:' + color + ';padding-left:6px">' + escHtml(t.status) + (t.why ? ' <span class="muted">· ' + escHtml(t.why) + '</span>' : '') + '</td></tr>';
|
||||
}
|
||||
return h + '</table>';
|
||||
}
|
||||
function tuneResultHTML(r){
|
||||
let h = '<div class="pe-note muted">Score : durée d\'un tour type — ' + (r.weights.k).toFixed(0) + ' jetons lus (cache repris), '
|
||||
+ (r.weights.g).toFixed(0) + ' écrits, ' + (r.weights.f_cold*100).toFixed(0) + ' % des tours relus à froid à ' + r.depth + ' jetons ('
|
||||
+ escHtml(r.weights.from) + '). Un gain compte au-delà de 3 % et de l\'écart entre passages. Moteur ' + escHtml(r.engine||'?') + '.</div>';
|
||||
h += tuneTrialsHTML([r.baseline].concat(r.trials||[]));
|
||||
for(const s of (r.skipped||[])) h += '<div class="muted" style="font-size:11px">sauté — ' + escHtml(s) + '</div>';
|
||||
for(const n of (r.notes||[])) h += '<div style="font-size:11px;color:var(--warn)">' + escHtml(n) + '</div>';
|
||||
if(r.partial) h += '<div style="font-size:11px;color:var(--warn)">résultat partiel : ' + escHtml(r.partial) + '</div>';
|
||||
if(r.best){
|
||||
h += '<div style="margin-top:6px"><b style="color:var(--ok)">Meilleur : ' + escHtml(r.best) + '</b> — −' + (r.gain*100).toFixed(1) + ' % par tour</div>'
|
||||
+ '<div class="pe-note" style="white-space:pre-wrap;font-family:var(--mono,monospace);font-size:11px">' + escHtml((r.changes||[]).join('\n')) + '</div>';
|
||||
if(r.placement) h += '<div class="pe-note" style="color:var(--warn)">Ce résultat réécrit EXTRA_ARGS (placement) : à confirmer à part.</div>';
|
||||
} else {
|
||||
h += '<div style="margin-top:6px">La configuration actuelle reste la meilleure : rien à changer.</div>';
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function tuneWatch(){
|
||||
clearTimeout(tunePoll);
|
||||
const body = document.getElementById('tune-body');
|
||||
const tick = async () => {
|
||||
let st;
|
||||
try{ st = await jget('/api/tune/status'); }
|
||||
catch(_){ tunePoll = setTimeout(tick, 3000); return; }
|
||||
if(st.running){
|
||||
tuneBtns('running');
|
||||
const eta = st.eta_sec >= 0 ? ' · reste ~' + tuneSec(st.eta_sec) : '';
|
||||
body.innerHTML = '<div class="muted" style="text-align:center">⏳ ' + escHtml(st.phase||'…') + '<br>' + tuneSec(st.elapsed_sec) + eta
|
||||
+ '<br><span style="font-size:11px">moteur principal arrêté · chat et tâches en pause</span></div>' + tuneTrialsHTML(st.trials);
|
||||
tunePoll = setTimeout(tick, 2000);
|
||||
return;
|
||||
}
|
||||
let h = '';
|
||||
if(st.error) h += '<div style="color:var(--err)">' + escHtml(st.error) + '</div>';
|
||||
if(st.result) h += tuneResultHTML(st.result);
|
||||
const a = st.apply;
|
||||
if(a){
|
||||
h += '<div class="pe-note">' + (a.log||[]).map(escHtml).join('<br>') + '</div>';
|
||||
if(a.message) h += '<div style="color:var(--ok)">' + escHtml(a.message) + '</div>';
|
||||
if(a.error) h += '<div style="color:var(--err)">' + escHtml(a.error) + '</div>';
|
||||
}
|
||||
body.innerHTML = h || '<div class="muted">rien à montrer</div>';
|
||||
const canApply = !!(st.result && st.result.best) && !st.applying && !(a && a.message);
|
||||
tuneBtns(canApply ? 'result' : 'done');
|
||||
if(st.applying){ tunePoll = setTimeout(tick, 2000); }
|
||||
};
|
||||
tick();
|
||||
}
|
||||
async function applyTuneUI(target){
|
||||
let st = {};
|
||||
try{ st = await jget('/api/tune/status'); }catch(_){}
|
||||
const r = st.result;
|
||||
if(!r || !r.best) return;
|
||||
const diff = (r.changes||[]).map(c => '• ' + c).join('\n');
|
||||
if(target === 'copy'){
|
||||
if(!await askConfirm('Créer une COPIE de « ' + r.preset_name + ' » avec :\n\n' + diff + '\n\nLe preset d\'origine ne bouge pas.',
|
||||
{title:'Enregistrer dans une copie', okText:'Créer la copie'})) return;
|
||||
} else {
|
||||
if(!await askConfirm('Réécrire « ' + r.preset_name + ' » (sauvegardé avant) avec :\n\n' + diff
|
||||
+ '\n\nLe moteur redémarre, puis une sonde vérifie un prompt à ' + r.depth + ' jetons ; en cas d\'échec, l\'ancienne version est rétablie.',
|
||||
{title:'Appliquer à ce preset', okText:'Appliquer'})) return;
|
||||
if(r.placement && !await askConfirm('Ce résultat RÉÉCRIT EXTRA_ARGS (placement des experts ou --tensor-split retirés pour --fit). Confirmer ?',
|
||||
{title:'Placement', okText:'Confirmer'})) return;
|
||||
}
|
||||
let res;
|
||||
try{ res = await jpost('/api/tune/apply', {target}); }catch(e){ res = {ok:false, error:e.message}; }
|
||||
if(!res.ok){ toast('erreur : ' + (res.error||'')); return; }
|
||||
if(target === 'copy'){ toast(res.message || 'copie créée'); KINDS.preset.reload(); return; }
|
||||
tuneWatch();
|
||||
}
|
||||
@@ -565,6 +565,9 @@ func handlePresetAutoPlace(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handlePresetSave(w http.ResponseWriter, r *http.Request) {
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
var req presetSaveReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
@@ -703,6 +706,9 @@ func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
|
||||
// key?} l'écrit puis redémarre le service (llama-server lit --api-key au lancement).
|
||||
func handleAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
Key string `json:"key"`
|
||||
@@ -1300,6 +1306,9 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
|
||||
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
|
||||
// utilisable même sans avoir créé de preset.
|
||||
func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
@@ -1327,6 +1336,11 @@ func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
// Pendant une optimisation, la configuration active est celle qu'elle mesure
|
||||
// et le moteur ne doit pas redémarrer (backend_tune_lock.go).
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
// id = identité stable du preset (nom de fichier). Le numéro seul est
|
||||
// dangereux : si la liste a changé depuis l'affichage (réordonnée, preset
|
||||
// ajouté ou supprimé depuis un autre appareil), la même position désigne un
|
||||
|
||||
@@ -182,6 +182,9 @@ func engineKeepAfterUpdate(bin, ancien string, logf func(string)) map[string]boo
|
||||
|
||||
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
|
||||
func engineSwitchTo(bin string) error {
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
wasUp := serviceIsActive()
|
||||
if wasUp {
|
||||
lcPhase("arrêt du moteur…")
|
||||
@@ -209,6 +212,9 @@ func engineSwitchTo(bin string) error {
|
||||
// de l'image ({tag:"image"}). Aucun réseau : c'est la sortie de secours quand une
|
||||
// mise à jour s'avère décevante.
|
||||
func handleEngineUse(w http.ResponseWriter, r *http.Request) {
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ func lcPhase(phase string) {
|
||||
|
||||
// startLcJob démarre un job (install ou update) si aucun n'est en cours.
|
||||
func startLcJob(action string, run func()) error {
|
||||
// Compiler, installer ou changer de moteur l'arrête et le relance : pas pendant
|
||||
// une optimisation (backend_tune_lock.go).
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
lcMu.Lock()
|
||||
defer lcMu.Unlock()
|
||||
if lcCur != nil && lcCur.Running {
|
||||
@@ -430,6 +435,9 @@ func lcRunPrebuilt() {
|
||||
// service redémarre pour prendre le nouveau binaire. L'UI n'appelle ceci que
|
||||
// quand la version cible existe déjà (sinon elle lance un job d'installation).
|
||||
func handleLlamacppUse(w http.ResponseWriter, r *http.Request) {
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
@@ -139,6 +139,9 @@ func newWebMux() *http.ServeMux {
|
||||
// Jeton de tentative du décodage spéculatif automatique : effacé dès que le
|
||||
// moteur répond, sans dépendre d'une page ouverte (voir backend_serve_spec.go).
|
||||
startSpecAttemptWatch()
|
||||
// Optimisation interrompue (Loki tué en plein essai) : essai orphelin arrêté,
|
||||
// moteur relancé s'il tournait avant (backend_tune_lock.go).
|
||||
go tuneRecoverAtBoot()
|
||||
mux := http.NewServeMux()
|
||||
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
|
||||
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
|
||||
@@ -336,6 +339,11 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/bench/status", handleBenchStatus) // progression, puis résultat
|
||||
api("/api/bench/cancel", handleBenchCancel) // POST : annule
|
||||
api("/api/bench/last", handleBenchLast)
|
||||
api("/api/tune", handleTune) // POST : optimiseur sans perte, en arrière-plan (backend_tune_job.go)
|
||||
api("/api/tune/status", handleTuneStatus) // progression, puis résultat
|
||||
api("/api/tune/cancel", handleTuneCancel) // POST : annule
|
||||
api("/api/tune/apply", handleTuneApply) // POST : écrit le résultat, sur clic seulement
|
||||
api("/api/tune/last", handleTuneLast)
|
||||
api("/api/perf/summary", handlePerfSummary)
|
||||
api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil
|
||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||
|
||||
@@ -319,6 +319,9 @@ func handleVramReload(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
if serviceIsActive() {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "already": true})
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user