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:
MichaelandClaude Opus 5.5 committed 2026-10-04 11:56:11 +02:00
1 parent f87c7b615b
commit 9bffff78c2
36 files changed
+4580 -16

No files matched your search

+81 -1
View File
@@ -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
+3
View File
@@ -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:
+4
View File
@@ -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
}
+3
View File
@@ -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
+3
View File
@@ -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.
+43 -5
View File
@@ -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
+378
View File
@@ -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()
}
+274
View File
@@ -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
+717
View File
@@ -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)
}
}
}
+17 -1
View File
@@ -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()
+1 -1
View File
@@ -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() {
+23
View File
@@ -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) {
+9
View File
@@ -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)
+12 -2
View File
@@ -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
}
+7
View File
@@ -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)
+10
View File
@@ -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
+38
View File
@@ -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
}
+30
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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()
+55
View File
@@ -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)
}
+78
View File
@@ -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
}
+3
View File
@@ -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
}
+2 -1
View File
@@ -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()
+201
View File
@@ -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>
+24
View File
@@ -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@@
+177
View File
@@ -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();
}
+14
View File
@@ -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
+6
View File
@@ -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"`
}
+8
View File
@@ -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"`
}
+8
View File
@@ -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)
+3
View File
@@ -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