mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
En ligne de commande, « loki tune » et le processus web avec des LOKI_HOME différents (sudo qui retire un LOKI_HOME exporté, variable posée dans un seul shell) ne se voient pas : le verrou tombait dans un dossier que l'interface ne lit pas, l'arrêt du « vrai » moteur visait celui d'une autre configuration, et l'essai chargeait à côté d'un moteur bien vivant — VRAM saturée, mesures fausses, relance possible par l'interface en pleine mesure. - Avant de commencer, la CLI regarde ce qui répond sur le port de son LOKI_HOME : un llama-server (/health 200, 503 ou 401) alors que ce dossier dit son moteur arrêté, un 401 sur /props avec sa clé d'API, ou un model_path qui n'est pas son MODEL (même fichier vérifié par os.SameFile) — refus, avec LOKI_HOME en clair et la marche à suivre (relancer avec le bon LOKI_HOME, ou le bouton « Optimiser… »). - Tout ce qui ne conclut pas laisse passer : rien n'écoute, autre service (404), /props absent d'un moteur ancien, chemin illisible d'ici. Le processus web n'est pas concerné : il fait foi pour son moteur. - Test : faux moteurs httptest pour chaque cas, refus et passages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1104 lines
38 KiB
Go
1104 lines
38 KiB
Go
package loki
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// Optimiseur sans perte — le déroulé (la partie pure est dans backend_tune.go).
|
||
//
|
||
// 1. Refus d'entrée : preset externe, aucun preset actif, génération, tâche,
|
||
// bench ou job en cours, moteur occupé par un client.
|
||
// 2. Verrou exclusif (backend_tune_lock.go), vrai moteur arrêté comme le fait
|
||
// « décharger la VRAM ».
|
||
// 3. Chaque essai : une COPIE de la configuration avec ses surcharges, posée
|
||
// dans LOKI_HOME/tune/run/<essai>/config.env — jamais config.env — puis
|
||
// « loki serve » à blanc (la ligne de commande, comparée à la référence
|
||
// avant tout chargement), puis pour de vrai sur 127.0.0.1 et un port libre.
|
||
// Mesure : le bench complet du lot 1 (llm_bench.go) à une profondeur fixe.
|
||
// Le groupe de processus de l'essai est tué en sortie, quoi qu'il arrive.
|
||
// 4. Fin : le vrai moteur est relancé (comme handleVramReload), le verrou
|
||
// rendu. Le résultat est montré ; RIEN n'est écrit dans un preset sans un
|
||
// geste de l'utilisateur (tuneApply).
|
||
|
||
// tuneOpts : ce que l'utilisateur a demandé.
|
||
type tuneOpts struct {
|
||
Placement bool // « inclure le placement » (réécrit EXTRA_ARGS pour --fit)
|
||
OptIn bool // « inclure les options opt-in » (SPEC, CUDA_GRAPH_OPT, --backend-sampling)
|
||
Budget time.Duration // 0 = tuneDefaultBudget
|
||
Via string // « web » ou « cli »
|
||
// Stages : étapes de base à faire (marges, lots, délestage, threads, files) ;
|
||
// vide = toutes. Placement et opt-in ont leur propre case.
|
||
Stages []string
|
||
}
|
||
|
||
const (
|
||
tuneDefaultBudget = 30 * time.Minute
|
||
tuneMaxBudget = 6 * time.Hour
|
||
)
|
||
|
||
// tuneLoadTimeout : chargement d'un essai. Un MoE de 80 Go relu depuis un
|
||
// disque lent prend plusieurs minutes ; au-delà, l'essai est déclaré en échec.
|
||
var tuneLoadTimeout = 20 * time.Minute
|
||
|
||
// tuneTrial : un essai, tel que montré à l'utilisateur.
|
||
type tuneTrial struct {
|
||
Stage string `json:"stage"`
|
||
Label string `json:"label"`
|
||
Status string `json:"status"` // en cours, mesuré, retenu, écarté, échec, doublon
|
||
Why string `json:"why,omitempty"`
|
||
Runs []tuneRun `json:"runs,omitempty"`
|
||
TurnSec float64 `json:"turn_sec,omitempty"` // moyenne des passages
|
||
Gain float64 `json:"gain"` // contre la meilleure configuration du moment
|
||
Headroom int `json:"headroom_mib"` // VRAM libre minimale après le bench ; -1 = non mesurée
|
||
Offloaded string `json:"offloaded,omitempty"`
|
||
Changes []string `json:"changes,omitempty"` // contre la référence
|
||
Sec float64 `json:"sec"`
|
||
}
|
||
|
||
// tuneResult : l'issue d'une optimisation.
|
||
type tuneResult struct {
|
||
PresetID string `json:"preset_id"`
|
||
PresetName string `json:"preset_name"`
|
||
BaseFP string `json:"base_fp"` // configFingerprint de la configuration mesurée
|
||
PresetFP string `json:"preset_fp"` // presetFingerprint du preset au départ
|
||
Engine string `json:"engine"` // build_info du moteur
|
||
EngineBuild int `json:"engine_build"`
|
||
Weights tuneWeights `json:"weights"`
|
||
Depth int `json:"depth"`
|
||
NCtx int `json:"n_ctx"`
|
||
Slots int `json:"slots"`
|
||
Baseline tuneTrial `json:"baseline"`
|
||
Trials []tuneTrial `json:"trials"`
|
||
Best string `json:"best,omitempty"` // libellé de l'essai retenu ; vide = la référence reste la meilleure
|
||
Gain float64 `json:"gain"` // contre la référence
|
||
Set map[string]string `json:"set,omitempty"` // clés changées et leur nouvelle valeur ("" = retirée)
|
||
Old map[string]string `json:"old,omitempty"` // leur valeur mesurée en référence
|
||
Changes []string `json:"changes,omitempty"`
|
||
Placement bool `json:"placement"` // EXTRA_ARGS réécrit : confirmation à part
|
||
Partial string `json:"partial,omitempty"`
|
||
Skipped []string `json:"skipped,omitempty"`
|
||
Notes []string `json:"notes,omitempty"`
|
||
OptIn bool `json:"opt_in"`
|
||
At int64 `json:"at"`
|
||
}
|
||
|
||
// tuneTracker : la progression, lue par l'interface ou affichée par la CLI.
|
||
type tuneTracker struct {
|
||
mu sync.Mutex
|
||
phase string
|
||
started time.Time
|
||
ended time.Time // fin de la mesure ; zéro = en cours
|
||
trials []tuneTrial
|
||
avgSec float64 // durée moyenne d'un essai mesuré
|
||
left int // essais encore prévus (estimation)
|
||
budget time.Duration
|
||
print bool
|
||
}
|
||
|
||
func (t *tuneTracker) setPhase(format string, a ...any) {
|
||
t.mu.Lock()
|
||
t.phase = fmt.Sprintf(format, a...)
|
||
p := t.print
|
||
t.mu.Unlock()
|
||
if p {
|
||
fmt.Printf(" %s %s\n", dim(time.Now().Format("15:04:05")), fmt.Sprintf(format, a...))
|
||
}
|
||
}
|
||
|
||
func (t *tuneTracker) put(i int, tr tuneTrial) int {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
if i < 0 || i >= len(t.trials) {
|
||
t.trials = append(t.trials, tr)
|
||
return len(t.trials) - 1
|
||
}
|
||
t.trials[i] = tr
|
||
return i
|
||
}
|
||
|
||
func (t *tuneTracker) snapshot() map[string]any {
|
||
t.mu.Lock()
|
||
defer t.mu.Unlock()
|
||
end := t.ended
|
||
if end.IsZero() {
|
||
end = time.Now()
|
||
}
|
||
el := end.Sub(t.started)
|
||
eta := -1.0
|
||
if t.avgSec > 0 {
|
||
eta = min(t.avgSec*float64(t.left), (t.budget - el).Seconds())
|
||
eta = max(eta, 0)
|
||
}
|
||
return map[string]any{"phase": t.phase, "elapsed_sec": el.Seconds(), "eta_sec": eta,
|
||
"trials": append([]tuneTrial(nil), t.trials...)}
|
||
}
|
||
|
||
// tuneActivePreset : le preset dont l'empreinte est celle de la configuration
|
||
// active — c'est lui que l'optimisation mesure, et lui seul qu'elle peut
|
||
// réécrire. Aucun : la mesure n'aurait nulle part où aller.
|
||
func tuneActivePreset() (Preset, string, error) {
|
||
list, err := ListPresets()
|
||
if err != nil {
|
||
return Preset{}, "", err
|
||
}
|
||
for _, p := range list {
|
||
if p.Active {
|
||
content, err := ReadPreset(p.ID)
|
||
return p, content, err
|
||
}
|
||
}
|
||
return Preset{}, "", errors.New("aucun preset ne correspond à la configuration active : enregistre-la comme preset " +
|
||
"(ou bascule sur l'un d'eux) avant d'optimiser")
|
||
}
|
||
|
||
// tuneAuth : l'en-tête du moteur protégé par clé (essai comme vrai moteur).
|
||
func tuneAuth() func(set func(k, v string)) {
|
||
key, _ := effectiveAPIKeyErr()
|
||
return func(set func(k, v string)) {
|
||
if key != "" {
|
||
set("Authorization", "Bearer "+key)
|
||
}
|
||
}
|
||
}
|
||
|
||
// tunePreflight : tout ce qui interdit de commencer, dans CE processus. Le
|
||
// processus web voit la discussion, les tâches et les jobs ; la CLI ne voit que
|
||
// le moteur (/slots).
|
||
func tunePreflight(ctx context.Context, inWeb bool) error {
|
||
if externalActive() {
|
||
return errors.New("optimisation indisponible : le preset actif est une API externe")
|
||
}
|
||
if err := tuneGuard(); err != nil {
|
||
return err
|
||
}
|
||
if _, _, err := tuneActivePreset(); err != nil {
|
||
return err
|
||
}
|
||
if inWeb {
|
||
switch {
|
||
case benchRunning():
|
||
return errBenchBusy
|
||
case conv.isGenerating():
|
||
return conv.busyReason()
|
||
case bgJobsRunning() > 0:
|
||
return fmt.Errorf("%d job(s) d'arrière-plan du mode code tournent encore (bash_bg) : ils fausseraient les mesures — arrête-les d'abord",
|
||
bgJobsRunning())
|
||
}
|
||
}
|
||
// En ligne de commande : le moteur qui répond sur le port de CE LOKI_HOME
|
||
// est-il bien le sien ? (Le processus web, lui, fait foi pour son moteur.)
|
||
if !inWeb {
|
||
cfg := ReadConfig()
|
||
ours, _ := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"]))
|
||
auth := tuneAuth()
|
||
if err := tuneForeignEngine(ctx, fmt.Sprintf("http://localhost:%d", LLMPort()), serviceIsActive(),
|
||
func(r *http.Request) { auth(r.Header.Set) }, ours); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if serviceIsActive() {
|
||
e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient}
|
||
if err := e.idle(ctx); err != nil {
|
||
return errors.New("le moteur traite une requête (discussion, tâche ou client /v1) : réessaie une fois libre")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// tuneForeignEngine : « loki tune » et le processus web avec des LOKI_HOME
|
||
// différents (sudo qui retire la variable, LOKI_HOME exporté dans un seul
|
||
// shell) ne se voient pas : le verrou de l'optimisation tombe dans un dossier
|
||
// que l'interface ne lit pas, et l'arrêt du « vrai » moteur vise celui d'une
|
||
// autre configuration. L'essai chargeait alors à côté d'un moteur bien vivant
|
||
// — VRAM saturée, mesures fausses — et l'interface pouvait relancer le sien
|
||
// en pleine mesure. On regarde donc ce qui répond sur le port de CE
|
||
// LOKI_HOME :
|
||
//
|
||
// - un serveur répond alors que ce LOKI_HOME dit son moteur arrêté : un autre
|
||
// Loki, ou un llama-server orphelin ;
|
||
// - le moteur refuse la clé d'API de ce LOKI_HOME (401 sur /props), ou sert
|
||
// un autre fichier que son MODEL : le moteur d'une autre configuration.
|
||
//
|
||
// Tout ce qui ne conclut pas (pas de réponse, /props absent d'un moteur
|
||
// ancien, chemin illisible d'ici) laisse passer : seul un désaccord constaté
|
||
// refuse.
|
||
func tuneForeignEngine(ctx context.Context, base string, active bool, auth func(*http.Request), ourModel string) error {
|
||
p := tplProber{base: base, auth: auth, client: &http.Client{Timeout: 2 * time.Second}}
|
||
// /health d'un llama-server : 200, 503 en chargement, 401 derrière une clé
|
||
// sur certaines versions. Rien n'écoute, ou un autre service (404…) : aucun
|
||
// moteur à craindre.
|
||
var he *tplHTTPError
|
||
if _, err := p.do(ctx, http.MethodGet, "/health", nil); err != nil &&
|
||
(!errors.As(err, &he) || (he.status != http.StatusServiceUnavailable && he.status != http.StatusUnauthorized)) {
|
||
return nil
|
||
}
|
||
home := LokiHome()
|
||
foreign := func(why string) error {
|
||
return fmt.Errorf("%s, alors que LOKI_HOME=%s — un autre Loki tourne sans doute avec un autre LOKI_HOME "+
|
||
"(sudo retire la variable) : relance avec le sien (LOKI_HOME=… loki tune) ou utilise le bouton "+
|
||
"« Optimiser… » de l'interface ; un llama-server orphelin, lui, s'arrête à la main", why, home)
|
||
}
|
||
if !active {
|
||
return foreign("un moteur répond sur " + base + " sans que ce dossier le suive")
|
||
}
|
||
props, err := p.props(ctx)
|
||
switch {
|
||
case errors.As(err, &he) && he.status == http.StatusUnauthorized:
|
||
return foreign("le moteur de " + base + " refuse la clé d'API de ce dossier")
|
||
case err != nil || props.ModelPath == "" || ourModel == "":
|
||
return nil
|
||
}
|
||
a, errA := os.Stat(props.ModelPath)
|
||
b, errB := os.Stat(ourModel)
|
||
if errA == nil && errB == nil && !os.SameFile(a, b) {
|
||
return foreign(fmt.Sprintf("le moteur de %s sert %s, pas le MODEL de ce dossier (%s ; config.env modifiée "+
|
||
"sans redémarrage ? « loki restart »)", base, filepath.Base(props.ModelPath), filepath.Base(ourModel)))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// tunePhysCores : cœurs physiques (Linux ; ailleurs inconnu, l'axe threads est
|
||
// sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite.
|
||
func tunePhysCores(probe *tuneProbe) int {
|
||
if probe != nil && probe.CPUThreads > 0 {
|
||
return probe.CPUThreads
|
||
}
|
||
fsys := os.DirFS("/")
|
||
online, err := readCPUList(fsys, "sys/devices/system/cpu/online")
|
||
if err != nil || len(online) == 0 {
|
||
return 0
|
||
}
|
||
atom, _ := readCPUList(fsys, "sys/devices/cpu_atom/cpus")
|
||
n, err := physicalCores(fsys, online, atom)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
func tuneFreePort() (int, error) {
|
||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer l.Close()
|
||
return l.Addr().(*net.TCPAddr).Port, nil
|
||
}
|
||
|
||
// tuneTrialer lance les essais d'une optimisation.
|
||
type tuneTrialer struct {
|
||
dir string
|
||
self string
|
||
lock *tuneLock
|
||
seq int
|
||
optIn bool
|
||
}
|
||
|
||
// prepare pose la configuration d'un essai (copie + adresse privée) dans un
|
||
// dossier neuf.
|
||
func (t *tuneTrialer) prepare(cfg map[string]string) (dir string, port int, err error) {
|
||
t.seq++
|
||
dir = filepath.Join(t.dir, fmt.Sprintf("e%02d", t.seq))
|
||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||
return "", 0, err
|
||
}
|
||
if port, err = tuneFreePort(); err != nil {
|
||
return "", 0, err
|
||
}
|
||
c := copyCfg(cfg)
|
||
c["HOST"], c["PORT"] = "127.0.0.1", strconv.Itoa(port)
|
||
return dir, port, writeTuneTrialConfig(dir, c)
|
||
}
|
||
|
||
// dry : « loki serve » à blanc — la ligne de commande, rien de chargé.
|
||
func (t *tuneTrialer) dry(ctx context.Context, dir string) (tuneLaunch, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||
defer cancel()
|
||
cmd := hideCmd(exec.CommandContext(ctx, t.self, "serve"))
|
||
cmd.Dir = LokiHome()
|
||
cmd.Env = append(os.Environ(), tuneTrialEnv+"="+dir, tuneDryRunEnv+"=1")
|
||
out, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
msg := strings.TrimSpace(ansiRe.ReplaceAllString(string(out), ""))
|
||
if i := strings.LastIndex(msg, "\n"); i >= 0 {
|
||
msg = msg[i+1:]
|
||
}
|
||
if msg == "" {
|
||
msg = err.Error()
|
||
}
|
||
return tuneLaunch{}, errors.New(msg)
|
||
}
|
||
return readTuneLaunch(dir)
|
||
}
|
||
|
||
// tuneLoaded : un essai chargé et prêt.
|
||
type tuneLoaded struct {
|
||
eng benchEngine
|
||
nCtx int
|
||
slots int
|
||
build string
|
||
offloaded int
|
||
layers int
|
||
kv string
|
||
log string
|
||
}
|
||
|
||
// tuneProps lit n_ctx, total_slots et build_info du moteur.
|
||
func tuneProps(ctx context.Context, e benchEngine) (nCtx, slots int, build string, err error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
defer cancel()
|
||
data, err := e.do(ctx, http.MethodGet, "/props", nil)
|
||
if err != nil {
|
||
return 0, 0, "", err
|
||
}
|
||
var p struct {
|
||
BuildInfo string `json:"build_info"`
|
||
TotalSlots int `json:"total_slots"`
|
||
NCtx int `json:"n_ctx"`
|
||
Default struct {
|
||
NCtx int `json:"n_ctx"`
|
||
} `json:"default_generation_settings"`
|
||
}
|
||
if err := json.Unmarshal(data, &p); err != nil {
|
||
return 0, 0, "", err
|
||
}
|
||
nCtx = p.Default.NCtx
|
||
if nCtx <= 0 {
|
||
nCtx = p.NCtx
|
||
}
|
||
return nCtx, p.TotalSlots, p.BuildInfo, nil
|
||
}
|
||
|
||
// load lance l'essai pour de vrai et attend qu'il réponde. La fonction rendue
|
||
// l'arrête (groupe entier), efface sa trace du verrou et attend que la VRAM
|
||
// soit rendue : à appeler en defer, quoi qu'il arrive.
|
||
func (t *tuneTrialer) load(ctx context.Context, dir string, port int, launch tuneLaunch) (*tuneLoaded, func(), error) {
|
||
logPath := filepath.Join(dir, "engine.log")
|
||
logf, err := os.Create(logPath)
|
||
if err != nil {
|
||
return nil, func() {}, err
|
||
}
|
||
cmd := exec.Command(t.self, "serve")
|
||
cmd.Dir = LokiHome()
|
||
cmd.Env = append(os.Environ(), tuneTrialEnv+"="+dir)
|
||
cmd.Stdout, cmd.Stderr = logf, logf
|
||
tuneTrialAttr(cmd)
|
||
if err := cmd.Start(); err != nil {
|
||
logf.Close()
|
||
return nil, func() {}, err
|
||
}
|
||
pid := cmd.Process.Pid
|
||
done := make(chan struct{})
|
||
go func() {
|
||
_ = cmd.Wait()
|
||
logf.Close()
|
||
close(done)
|
||
}()
|
||
proc := tuneProc{PID: pid, Exes: []string{t.self, launch.Bin}}
|
||
if start, _, ok := procIdentity(pid); ok {
|
||
proc.Start = start
|
||
}
|
||
t.lock.setTrial(&proc)
|
||
stop := func() {
|
||
before := gpuUsedMB(gpuStats())
|
||
tuneKillTree(pid, done)
|
||
// Noté jusqu'à sa fin constatée : un essai qui survit à l'arrêt reste
|
||
// désigné dans le verrou, qu'un Loki redémarré pourra encore arrêter.
|
||
select {
|
||
case <-done:
|
||
t.lock.setTrial(nil)
|
||
default:
|
||
fmt.Fprintf(os.Stderr, "[loki tune] l'essai (PID %d) ne s'est pas arrêté dans les temps\n", pid)
|
||
}
|
||
if before > 0 {
|
||
gpuUsedSettled(before)
|
||
} else {
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
}
|
||
readLog := func() string {
|
||
b, _ := os.ReadFile(logPath)
|
||
return string(b)
|
||
}
|
||
e := benchEngine{base: fmt.Sprintf("http://127.0.0.1:%d", port), auth: tuneAuth(), client: http.DefaultClient}
|
||
deadline := time.Now().Add(tuneLoadTimeout)
|
||
hc := &http.Client{Timeout: 3 * time.Second}
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return nil, stop, ctx.Err()
|
||
case <-done:
|
||
return nil, stop, errors.New(tuneLoadFailure(readLog()))
|
||
case <-time.After(time.Second):
|
||
}
|
||
if resp, err := hc.Get(e.base + "/health"); err == nil {
|
||
resp.Body.Close()
|
||
if resp.StatusCode == http.StatusOK {
|
||
break
|
||
}
|
||
}
|
||
if time.Now().After(deadline) {
|
||
return nil, stop, fmt.Errorf("pas prêt après %s", tuneLoadTimeout)
|
||
}
|
||
}
|
||
nCtx, slots, build, err := tuneProps(ctx, e)
|
||
if err != nil {
|
||
return nil, stop, fmt.Errorf("/props illisible : %w", err)
|
||
}
|
||
log := readLog()
|
||
off, layers, kv := tuneLogFacts(log)
|
||
return &tuneLoaded{eng: e, nCtx: nCtx, slots: slots, build: build, offloaded: off, layers: layers, kv: kv, log: log}, stop, nil
|
||
}
|
||
|
||
// tuneBenchSetup : le bench du preset de l'essai — son échantillonnage et son
|
||
// raisonnement réels (une spéculation se juge sur le tirage du preset, pas en
|
||
// glouton), profondeur plafonnée comme celle de la référence.
|
||
func tuneBenchSetup(cfg map[string]string, argEnv map[string]string, cpuPlaced bool) benchSetup {
|
||
s := benchSetupFrom(cfg, argEnv)
|
||
s.learn = func(want, got string) { effortRemember(want, got) }
|
||
s.cpuPlaced = cpuPlaced
|
||
return s
|
||
}
|
||
|
||
// runTune déroule une optimisation. L'appelant fournit le suivi ; le verrou,
|
||
// l'arrêt et la relance du vrai moteur sont faits ici.
|
||
func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (out *tuneResult, retErr error) {
|
||
if opts.Budget <= 0 {
|
||
opts.Budget = tuneDefaultBudget
|
||
}
|
||
opts.Budget = min(opts.Budget, tuneMaxBudget)
|
||
tr.mu.Lock()
|
||
tr.started, tr.budget = time.Now(), opts.Budget
|
||
tr.mu.Unlock()
|
||
cfg := ReadConfig()
|
||
preset, content, err := tuneActivePreset()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
self, err := os.Executable()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
mainActive := engineNeedsStop()
|
||
lock, err := tuneLockAcquire(opts.Via, mainActive)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer lock.release()
|
||
// Le vrai moteur revient AVANT que le verrou ne soit rendu (les defer
|
||
// passent dans l'ordre inverse) : personne ne le relance par-dessus un essai.
|
||
defer func() {
|
||
if !mainActive {
|
||
return
|
||
}
|
||
lock.setPhase("relance")
|
||
tr.setPhase("relance du moteur principal")
|
||
// Un échec de relance doit se voir là où l'on regarde (résultat de
|
||
// l'interface, sortie de « loki tune »), pas seulement au journal.
|
||
failed := func(msg string) {
|
||
fmt.Fprintf(os.Stderr, "[loki tune] %s\n", msg)
|
||
switch {
|
||
case out != nil:
|
||
out.Notes = append(out.Notes, "⚠️ "+msg)
|
||
case retErr != nil:
|
||
retErr = fmt.Errorf("%w — %s", retErr, msg)
|
||
}
|
||
}
|
||
if err := preflightEngine(); err != nil {
|
||
failed("relance du moteur principal impossible : " + plainErr(err))
|
||
return
|
||
}
|
||
if err := serviceActionOS("start"); err != nil {
|
||
failed("relance du moteur principal : " + plainErr(err))
|
||
}
|
||
gpuMonitor.invalidate()
|
||
}()
|
||
|
||
runDir := filepath.Join(LokiHome(), "tune", "run")
|
||
_ = os.RemoveAll(runDir)
|
||
if err := os.MkdirAll(runDir, 0o700); err != nil {
|
||
return nil, err
|
||
}
|
||
t := &tuneTrialer{dir: runDir, self: self, lock: lock, optIn: opts.OptIn}
|
||
|
||
if mainActive {
|
||
tr.setPhase("arrêt du moteur principal")
|
||
gpus := gpuStats()
|
||
before := gpuUsedMB(gpus)
|
||
if err := serviceActionOS("stop"); err != nil {
|
||
return nil, fmt.Errorf("arrêt du moteur : %s", plainErr(err))
|
||
}
|
||
asrShutdownVite()
|
||
if gpus != nil {
|
||
gpuUsedSettled(before)
|
||
}
|
||
gpuMonitor.invalidate()
|
||
}
|
||
|
||
res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg),
|
||
PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(),
|
||
Weights: tuneWeightsFrom(perfLog.snapshot())}
|
||
|
||
// Référence : la ligne composée telle quelle, la sonde de la machine.
|
||
tr.setPhase("référence : composition de la ligne de commande")
|
||
baseDir, basePort, err := t.prepare(cfg)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
baseLaunch, err := t.dry(ctx, baseDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("la configuration active ne se lance pas : %s", err)
|
||
}
|
||
probe := baseLaunch.Probe
|
||
if probe == nil {
|
||
probe = &tuneProbe{}
|
||
}
|
||
smi := gpuStats()
|
||
env := tuneEnv{Help: probe.Help, GPUs: probe.GPUs, VRAMMiB: probe.VRAMMiB, ModelBytes: probe.ModelBytes,
|
||
MoE: probe.MoE, PhysCores: tunePhysCores(probe), SMI: smi != nil, ArgEnv: probe.ArgEnv,
|
||
UserEnv: probe.UserEnv, QueuesEnv: probe.QueuesEnv, Placement: opts.Placement, OptIn: opts.OptIn}
|
||
visible := tuneVisibleGPUs(cfg)
|
||
if env.VRAMMiB == 0 && smi != nil {
|
||
for i, g := range smi {
|
||
if len(visible) == 0 || containsInt(visible, i) {
|
||
env.VRAMMiB += int64(g.Total)
|
||
}
|
||
}
|
||
}
|
||
if env.GPUs == 0 && smi != nil {
|
||
env.GPUs = len(smi)
|
||
if len(visible) > 0 {
|
||
env.GPUs = len(visible)
|
||
}
|
||
}
|
||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||
kt, vt, _ := effectiveKVTypes(cfg, extra, env.ArgEnv)
|
||
if label, quant := benchKVLabel(kt, vt); quant {
|
||
res.Notes = append(res.Notes, "cache KV "+label+" quantifié : zone grise, conservé tel quel (jamais modifié par l'optimiseur)")
|
||
}
|
||
if !env.SMI {
|
||
res.Notes = append(res.Notes, "VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés")
|
||
}
|
||
if _, total := ramUsageMB(); total > 0 && env.ModelBytes > int64(total)<<20 {
|
||
res.Notes = append(res.Notes, "modèle plus gros que la RAM : chaque essai le relit depuis le disque — prévoir un budget "+
|
||
"plus long, ou ne garder que quelques étapes")
|
||
}
|
||
reserve := tuneTightMiB
|
||
if strings.TrimSpace(cfg["MMPROJ"]) != "" {
|
||
reserve += tuneVisionReserveMiB
|
||
}
|
||
cpuPlaced := tensorOverride(extra, env.ArgEnv) != ""
|
||
|
||
// La référence se mesure deux fois : son écart entre passages est le bruit
|
||
// que tout gain doit dépasser.
|
||
tr.setPhase("référence : chargement")
|
||
bi := tr.put(-1, tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "en cours", Headroom: -1})
|
||
t0 := time.Now()
|
||
base, stop, err := t.load(ctx, baseDir, basePort, baseLaunch)
|
||
if err != nil {
|
||
stop()
|
||
return nil, fmt.Errorf("la référence ne charge pas : %s", err)
|
||
}
|
||
res.Engine, res.NCtx, res.Slots = base.build, base.nCtx, base.slots
|
||
if m := reBuildInfo.FindStringSubmatch(base.build); m != nil {
|
||
res.EngineBuild, _ = strconv.Atoi(m[1])
|
||
}
|
||
// Une profondeur pour tous : celle que la référence peut tenir avec le plus
|
||
// gros préchauffage essayé (2 × 4096 jetons, au plus un quart du contexte).
|
||
warmMax := max(min(2*4096, base.nCtx/4), 64) + benchWarmGen
|
||
res.Depth, _ = benchDepthFor(base.nCtx, cpuPlaced, warmMax)
|
||
bopts := benchOpts{Mode: benchModeFull, Prompt: 2000, Predict: 300, Depth: res.Depth}
|
||
corp := defaultBenchCorpora()
|
||
measure := func(cfg map[string]string, l *tuneLoaded, n int, phase string) ([]tuneRun, error) {
|
||
var runs []tuneRun
|
||
for i := 0; i < n; i++ {
|
||
tr.setPhase("%s : mesure %d/%d", phase, i+1, n)
|
||
// SIDE_SLOT : deux slots ; les tours en profondeur restent sur le même
|
||
// (le second, comme le bench ordinaire), sans quoi la reprise du cache
|
||
// dépendrait du slot choisi par le moteur.
|
||
s := tuneBenchSetup(cfg, env.ArgEnv, cpuPlaced)
|
||
s.sideSlot = sideSlotOn(cfg) && l.slots >= 2
|
||
r, err := benchRun(ctx, l.eng, bopts, s, corp, nil)
|
||
if err != nil {
|
||
return runs, err
|
||
}
|
||
run, err := tuneRunFrom(r, res.Weights)
|
||
if err != nil {
|
||
return runs, err
|
||
}
|
||
runs = append(runs, run)
|
||
}
|
||
return runs, nil
|
||
}
|
||
headroom := func() (int, bool) {
|
||
if !env.SMI {
|
||
return -1, false
|
||
}
|
||
free, ok := tuneHeadroom(gpuStats(), visible)
|
||
if !ok {
|
||
return -1, false
|
||
}
|
||
return free, free < reserve
|
||
}
|
||
baseRuns, err := measure(cfg, base, 2, "référence")
|
||
hr, _ := headroom()
|
||
stop()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("mesure de la référence : %s", err)
|
||
}
|
||
bt := tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "mesuré", Runs: baseRuns,
|
||
TurnSec: turnMean(baseRuns), Headroom: hr, Sec: time.Since(t0).Seconds()}
|
||
if base.offloaded >= 0 {
|
||
bt.Offloaded = fmt.Sprintf("%d/%d", base.offloaded, base.layers)
|
||
}
|
||
res.Baseline = bt
|
||
tr.put(bi, bt)
|
||
if hr >= 0 && hr < reserve {
|
||
res.Notes = append(res.Notes, fmt.Sprintf("la configuration actuelle laisse déjà moins de %d Mio libres sur une carte", reserve))
|
||
}
|
||
|
||
best, bestRuns, bestLabel := cfg, baseRuns, ""
|
||
durations := []float64{bt.Sec}
|
||
measured := map[string]string{tuneLaunchKey(baseLaunch): bt.Label}
|
||
deadline := tr.started.Add(opts.Budget)
|
||
|
||
stages:
|
||
for si, st := range tuneStages {
|
||
if !tuneStageWanted(st.Name, opts.Stages) {
|
||
res.Skipped = append(res.Skipped, st.Name+" : non demandé")
|
||
continue
|
||
}
|
||
cands, why := st.Gen(best, env)
|
||
if why != "" {
|
||
res.Skipped = append(res.Skipped, st.Name+" : "+why)
|
||
continue
|
||
}
|
||
for ci, c := range cands {
|
||
// Ce qui reste à faire, pour l'estimation : les essais restants de
|
||
// l'étape, plus ceux des étapes suivantes vus depuis la meilleure
|
||
// configuration du moment.
|
||
left := len(cands) - ci
|
||
for _, nx := range tuneStages[si+1:] {
|
||
if !tuneStageWanted(nx.Name, opts.Stages) {
|
||
continue
|
||
}
|
||
more, _ := nx.Gen(best, env)
|
||
left += len(more)
|
||
}
|
||
avg := tuneMean(durations)
|
||
tr.mu.Lock()
|
||
tr.avgSec, tr.left = avg, left
|
||
tr.mu.Unlock()
|
||
if ctx.Err() != nil {
|
||
res.Partial = "annulé"
|
||
break stages
|
||
}
|
||
if time.Until(deadline).Seconds() < avg {
|
||
res.Partial = fmt.Sprintf("budget de %s atteint : %d essai(s) non faits", opts.Budget.Round(time.Minute), left)
|
||
break stages
|
||
}
|
||
trial := tuneTrial{Stage: c.Stage, Label: c.Label, Status: "en cours", Headroom: -1,
|
||
Changes: tuneDiff(cfg, c.Cfg)}
|
||
idx := tr.put(-1, trial)
|
||
finish := func(status, why string) {
|
||
trial.Status, trial.Why = status, why
|
||
tr.put(idx, trial)
|
||
res.Trials = append(res.Trials, trial)
|
||
}
|
||
if err := tuneCfgCheck(cfg, c.Cfg, opts.OptIn); err != nil {
|
||
finish("écarté", "dénature : "+err.Error())
|
||
continue
|
||
}
|
||
tr.setPhase("%s — %s : composition", st.Name, c.Label)
|
||
dir, port, err := t.prepare(c.Cfg)
|
||
if err != nil {
|
||
finish("échec", err.Error())
|
||
continue
|
||
}
|
||
launch, err := t.dry(ctx, dir)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
finish("échec", "annulé")
|
||
res.Partial = "annulé"
|
||
break stages
|
||
}
|
||
finish("écarté", "refusé au lancement : "+err.Error())
|
||
continue
|
||
}
|
||
if why := tuneDenature(baseLaunch, launch, opts.OptIn); why != "" {
|
||
finish("écarté", "dénature : "+why)
|
||
continue
|
||
}
|
||
key := tuneLaunchKey(launch)
|
||
if prev, ok := measured[key]; ok {
|
||
finish("doublon", "même ligne de commande que « "+prev+" »")
|
||
continue
|
||
}
|
||
measured[key] = c.Label
|
||
t1 := time.Now()
|
||
tr.setPhase("%s — %s : chargement", st.Name, c.Label)
|
||
l, stop, err := t.load(ctx, dir, port, launch)
|
||
if err != nil {
|
||
stop()
|
||
if ctx.Err() != nil {
|
||
finish("échec", "annulé")
|
||
res.Partial = "annulé"
|
||
break stages
|
||
}
|
||
finish("échec", err.Error())
|
||
durations = append(durations, time.Since(t1).Seconds())
|
||
continue
|
||
}
|
||
trial.Sec = time.Since(t1).Seconds()
|
||
if l.offloaded >= 0 {
|
||
trial.Offloaded = fmt.Sprintf("%d/%d", l.offloaded, l.layers)
|
||
}
|
||
// Contre-vérification à l'exécution : même contexte, mêmes slots,
|
||
// mêmes types de cache que la référence.
|
||
switch {
|
||
case l.nCtx != base.nCtx || l.slots != base.slots:
|
||
stop()
|
||
finish("écarté", fmt.Sprintf("dénature : contexte %d × %d slot(s) au lieu de %d × %d", l.nCtx, l.slots, base.nCtx, base.slots))
|
||
continue
|
||
case base.kv != "" && l.kv != "" && l.kv != base.kv:
|
||
stop()
|
||
finish("écarté", "dénature : cache KV "+l.kv+" au lieu de "+base.kv)
|
||
continue
|
||
}
|
||
fitRegressed := base.offloaded >= 0 && l.offloaded >= 0 && l.offloaded < base.offloaded
|
||
runs, err := measure(c.Cfg, l, 1, st.Name+" — "+c.Label)
|
||
if err == nil && len(runs) == 1 && tuneWorthRepeat(turnSecs(bestRuns), runs[0].TurnSec) && !fitRegressed {
|
||
var more []tuneRun
|
||
more, err = measure(c.Cfg, l, 1, st.Name+" — "+c.Label+" (confirmation)")
|
||
runs = append(runs, more...)
|
||
}
|
||
hr, tight := headroom()
|
||
stop()
|
||
trial.Sec = time.Since(t1).Seconds()
|
||
durations = append(durations, trial.Sec)
|
||
trial.Runs, trial.Headroom = runs, hr
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
finish("échec", "annulé")
|
||
res.Partial = "annulé"
|
||
break stages
|
||
}
|
||
finish("échec", "mesure : "+err.Error())
|
||
continue
|
||
}
|
||
trial.TurnSec = turnMean(runs)
|
||
gain, need, wins := tuneWins(turnSecs(bestRuns), turnSecs(runs))
|
||
trial.Gain = gain
|
||
proseBest, proseCand := proseMean(bestRuns), proseMean(runs)
|
||
switch {
|
||
case fitRegressed:
|
||
finish("écarté", fmt.Sprintf("fit en recul : %d couches déportées au lieu de %d (performance)", l.offloaded, base.offloaded))
|
||
case tight:
|
||
finish("écarté", fmt.Sprintf("trop juste : %d Mio libres sur une carte (< %d)", hr, reserve))
|
||
case proseBest > 0 && proseCand < 0.95*proseBest:
|
||
finish("mesuré", fmt.Sprintf("decode en prose plus lent de %.0f %%", (1-proseCand/proseBest)*100))
|
||
case wins:
|
||
best, bestRuns, bestLabel = c.Cfg, runs, c.Label
|
||
if c.Stage == "placement" {
|
||
res.Placement = true
|
||
}
|
||
finish("retenu", fmt.Sprintf("−%.1f %% par tour (seuil %.1f %%)", gain*100, need*100))
|
||
case len(runs) < 2 && gain > 0:
|
||
finish("mesuré", fmt.Sprintf("−%.1f %%, sous le seuil de %.0f %%", gain*100, tuneMinGain*100))
|
||
default:
|
||
delta := fmt.Sprintf("%+.1f %%", -gain*100)
|
||
if gain*1000 > -0.5 && gain*1000 < 0.5 {
|
||
delta = "≈ 0 %"
|
||
}
|
||
finish("mesuré", delta+fmt.Sprintf(" (seuil %.1f %%)", need*100))
|
||
}
|
||
}
|
||
}
|
||
tr.mu.Lock()
|
||
tr.left = 0
|
||
tr.mu.Unlock()
|
||
|
||
res.Best = bestLabel
|
||
if bestLabel != "" {
|
||
res.Gain = (turnMean(baseRuns) - turnMean(bestRuns)) / turnMean(baseRuns)
|
||
res.Changes = tuneDiff(cfg, best)
|
||
res.Set, res.Old = map[string]string{}, map[string]string{}
|
||
for k := range keysOf(cfg, best) {
|
||
if cfg[k] != best[k] {
|
||
res.Set[k], res.Old[k] = best[k], cfg[k]
|
||
}
|
||
}
|
||
}
|
||
if ctx.Err() != nil && res.Partial == "" {
|
||
res.Partial = "annulé"
|
||
}
|
||
saveTuneResult(res)
|
||
return res, nil
|
||
}
|
||
|
||
func turnSecs(r []tuneRun) []float64 {
|
||
out := make([]float64, len(r))
|
||
for i, x := range r {
|
||
out[i] = x.TurnSec
|
||
}
|
||
return out
|
||
}
|
||
|
||
func turnMean(r []tuneRun) float64 { return tuneMean(turnSecs(r)) }
|
||
|
||
func proseMean(r []tuneRun) float64 {
|
||
v := make([]float64, len(r))
|
||
for i, x := range r {
|
||
v[i] = x.ProseTG
|
||
}
|
||
return tuneMean(v)
|
||
}
|
||
|
||
func keysOf(ms ...map[string]string) map[string]bool {
|
||
out := map[string]bool{}
|
||
for _, m := range ms {
|
||
for k := range m {
|
||
out[k] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// --- résultats enregistrés -------------------------------------------------------
|
||
|
||
// saveTuneResult garde le dernier résultat par preset, avec l'empreinte et le
|
||
// build du moteur : après une mise à jour du moteur, l'interface propose de
|
||
// relancer l'optimisation.
|
||
func saveTuneResult(res *tuneResult) {
|
||
m := map[string]tuneResult{}
|
||
getJSON(bkState, "tune_results", &m)
|
||
if m == nil {
|
||
m = map[string]tuneResult{}
|
||
}
|
||
m[res.PresetID] = *res
|
||
_ = putJSON(bkState, "tune_results", m)
|
||
}
|
||
|
||
func loadTuneResult(id string) (*tuneResult, bool) {
|
||
m := map[string]tuneResult{}
|
||
if !getJSON(bkState, "tune_results", &m) {
|
||
return nil, false
|
||
}
|
||
r, ok := m[id]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
return &r, true
|
||
}
|
||
|
||
// --- application ------------------------------------------------------------------
|
||
|
||
// tuneApplyTarget : où écrire le résultat.
|
||
const (
|
||
tuneApplyCopy = "copy" // une copie du preset, non activée
|
||
tuneApplyPreset = "preset" // le preset mesuré lui-même, réappliqué et vérifié
|
||
)
|
||
|
||
// tunePlanApply prépare la réécriture sans rien écrire : le contenu du preset
|
||
// réglé et le diff des clés. Refusé si le preset a changé depuis la mesure.
|
||
func tunePlanApply(res *tuneResult) (content, patched string, err error) {
|
||
if res == nil || len(res.Set) == 0 {
|
||
return "", "", errors.New("rien à appliquer : la configuration actuelle reste la meilleure")
|
||
}
|
||
content, err = ReadPreset(res.PresetID)
|
||
if err != nil {
|
||
return "", "", fmt.Errorf("preset « %s » introuvable : %w", res.PresetName, err)
|
||
}
|
||
if presetFingerprint([]byte(content)) != res.PresetFP {
|
||
return "", "", errors.New("le preset a changé depuis la mesure : relance l'optimisation")
|
||
}
|
||
base := parseEnv(content)
|
||
win := copyCfg(base)
|
||
for k, v := range res.Set {
|
||
if v == "" {
|
||
delete(win, k)
|
||
} else {
|
||
win[k] = v
|
||
}
|
||
}
|
||
if err := tuneCfgCheck(base, win, res.OptIn); err != nil {
|
||
return "", "", err
|
||
}
|
||
patched, err = tunePatchPreset(content, base, win)
|
||
return content, patched, err
|
||
}
|
||
|
||
// tuneApply écrit le résultat : dans une COPIE du preset (rien d'autre ne
|
||
// bouge), ou dans le preset lui-même — il doit être encore actif. Dans ce cas
|
||
// le moteur redémarre sur la nouvelle version, qui doit répondre, garder son
|
||
// contexte et ses slots, et passer une sonde (un prompt à la profondeur mesurée
|
||
// puis un decode) ; sinon l'ancienne version est rétablie. L'appelant a la
|
||
// confirmation de l'utilisateur.
|
||
func tuneApply(ctx context.Context, res *tuneResult, target string, say func(string)) (string, error) {
|
||
content, patched, err := tunePlanApply(res)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
switch target {
|
||
case tuneApplyCopy:
|
||
name := res.PresetName + " (optimisé)"
|
||
id, err := SavePreset("", name, patched)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("copie « %s » créée (%s) — bascule dessus pour l'utiliser", name, id), nil
|
||
case tuneApplyPreset:
|
||
default:
|
||
return "", fmt.Errorf("cible inconnue : %s", target)
|
||
}
|
||
if externalActive() {
|
||
return "", errors.New("le preset actif est une API externe")
|
||
}
|
||
cur := ReadConfig()
|
||
if presetFingerprint([]byte(content)) != configFingerprint(cur) {
|
||
return "", errors.New("ce preset n'est plus celui en service : bascule dessus, ou enregistre le résultat dans une copie")
|
||
}
|
||
if configFingerprint(cur) != res.BaseFP {
|
||
return "", errors.New("la configuration active a changé depuis la mesure : relance l'optimisation")
|
||
}
|
||
lock, err := tuneLockAcquire("apply", serviceIsActive())
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer lock.release()
|
||
lock.setPhase("application")
|
||
backup := filepath.Join(LokiHome(), "tune", "backup", fmt.Sprintf("%s-%d.env", res.PresetID, time.Now().Unix()))
|
||
_ = os.MkdirAll(filepath.Dir(backup), 0o700)
|
||
if err := os.WriteFile(backup, []byte(content), 0o600); err != nil {
|
||
return "", fmt.Errorf("sauvegarde du preset : %w", err)
|
||
}
|
||
// Noté dans le verrou AVANT d'écrire : un Loki tué pendant l'application
|
||
// la défait au redémarrage (tuneUndoApply).
|
||
lock.setBackup(backup, res.PresetID, res.PresetName)
|
||
revert := func(why string) (string, error) {
|
||
say("échec (" + why + ") : retour à l'ancienne version")
|
||
if _, _, err := SavePresetApplying(res.PresetID, res.PresetName, content); err != nil {
|
||
return "", fmt.Errorf("%s ; RETOUR IMPOSSIBLE (%v) — l'ancienne version est dans %s", why, err, backup)
|
||
}
|
||
if err := tuneSvc("restart"); err != nil {
|
||
return "", fmt.Errorf("%s ; ancienne version remise, redémarrage : %s", why, plainErr(err))
|
||
}
|
||
return "", fmt.Errorf("%s : ancienne version rétablie", why)
|
||
}
|
||
say("écriture du preset (sauvegarde : " + backup + ")")
|
||
_, applied, err := SavePresetApplying(res.PresetID, res.PresetName, patched)
|
||
if err != nil {
|
||
return revert("écriture : " + err.Error())
|
||
}
|
||
if !applied {
|
||
return revert("le preset n'a pas été appliqué à la configuration active")
|
||
}
|
||
// Même configuration que l'essai retenu, à l'empreinte près : même
|
||
// composition de la ligne de commande (buildServeArgs) que mesurée.
|
||
if why := tuneAppliedMismatch(ReadConfig(), res); why != "" {
|
||
return revert(why)
|
||
}
|
||
say("redémarrage du moteur")
|
||
if err := tuneSvc("restart"); err != nil {
|
||
return revert("redémarrage : " + plainErr(err))
|
||
}
|
||
if err := tuneProbeFn(ctx, res, say); err != nil {
|
||
return revert(err.Error())
|
||
}
|
||
return "preset « " + res.PresetName + " » optimisé et vérifié", nil
|
||
}
|
||
|
||
// tuneSvc et tuneProbeFn : serviceActionOS et tunePostApplyProbe, remplaçables
|
||
// dans les tests (application puis retour arrière sans vrai moteur).
|
||
var (
|
||
tuneSvc = serviceActionOS
|
||
tuneProbeFn = tunePostApplyProbe
|
||
tuneHealthFn = healthCheck
|
||
)
|
||
|
||
// tuneAppliedMismatch : la configuration active après application doit être
|
||
// EXACTEMENT celle de l'essai retenu — la référence mesurée plus les clés
|
||
// réglées. Remises à leur valeur de référence, on doit retrouver l'empreinte
|
||
// mesurée. Même configuration, même composition (buildServeArgs) : le moteur
|
||
// relancé est bien celui qui a gagné. "" = conforme.
|
||
func tuneAppliedMismatch(now map[string]string, res *tuneResult) string {
|
||
back := copyCfg(now)
|
||
for k, v := range res.Set {
|
||
if now[k] != v {
|
||
return "la configuration appliquée diffère de l'essai retenu sur " + k
|
||
}
|
||
if old := res.Old[k]; old == "" {
|
||
delete(back, k)
|
||
} else {
|
||
back[k] = old
|
||
}
|
||
}
|
||
if configFingerprint(back) != res.BaseFP {
|
||
return "la configuration a changé ailleurs que sur les clés réglées depuis la mesure"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// tunePostApplyProbe : le moteur redémarré répond, garde le contexte et les
|
||
// slots mesurés, et tient un prompt à la profondeur de la mesure suivi d'un
|
||
// decode — ce qu'un /health seul ne montre pas (un OOM au premier long prompt).
|
||
func tunePostApplyProbe(ctx context.Context, res *tuneResult, say func(string)) error {
|
||
say("attente du moteur")
|
||
e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient}
|
||
deadline := time.Now().Add(tuneLoadTimeout)
|
||
// Un moteur mort au chargement (OOM) se voit vite : plus de service du tout,
|
||
// trois fois de suite après les premières secondes. Inutile d'attendre le
|
||
// délai entier pour revenir à l'ancienne version.
|
||
gone, t0 := 0, time.Now()
|
||
for !tuneHealthFn() {
|
||
if time.Now().After(deadline) {
|
||
return fmt.Errorf("le moteur ne répond pas après %s", tuneLoadTimeout)
|
||
}
|
||
if time.Since(t0) > 10*time.Second && !engineNeedsStop() {
|
||
if gone++; gone >= 3 {
|
||
return errors.New("le moteur s'est arrêté au chargement")
|
||
}
|
||
} else {
|
||
gone = 0
|
||
}
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
case <-time.After(2 * time.Second):
|
||
}
|
||
}
|
||
nCtx, slots, _, err := tuneProps(ctx, e)
|
||
if err != nil {
|
||
return fmt.Errorf("/props illisible : %w", err)
|
||
}
|
||
if nCtx != res.NCtx || slots != res.Slots {
|
||
return fmt.Errorf("contexte %d × %d slot(s) au lieu de %d × %d", nCtx, slots, res.NCtx, res.Slots)
|
||
}
|
||
if res.Depth <= 0 {
|
||
return nil
|
||
}
|
||
say(fmt.Sprintf("sonde : prompt de %d jetons puis decode", res.Depth))
|
||
cfg := ReadConfig()
|
||
s := tuneBenchSetup(cfg, nil, false)
|
||
code := defaultBenchCorpora().code
|
||
sample, _ := benchSlice(code, 0, benchSampleBytes)
|
||
text, _ := benchSlice(code, 0, int(float64(res.Depth)*e.bytesPerToken(ctx, sample)))
|
||
defer engineSideJob()()
|
||
if _, err := s.ask(ctx, e, []Message{{Role: "user", Content: text + "\n\n" + benchAskFirst}}, 64, false); err != nil {
|
||
return fmt.Errorf("sonde : %w", err)
|
||
}
|
||
return nil
|
||
}
|