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

+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