Files
Loki/internal/loki/backend_tune_run.go
T
MichaelandClaude Opus 5.5 9bffff78c2 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>
2026-10-04 11:56:11 +02:00

1003 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}
if serviceIsActive() {
e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient}
if err := e.idle(ctx); err != nil {
return errors.New("le moteur traite une requête (discussion, tâche ou client /v1) : réessaie une fois libre")
}
}
return nil
}
// tunePhysCores : cœurs physiques (Linux ; ailleurs inconnu, l'axe threads est
// sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite.
func tunePhysCores(probe *tuneProbe) int {
if probe != nil && probe.CPUThreads > 0 {
return probe.CPUThreads
}
fsys := os.DirFS("/")
online, err := readCPUList(fsys, "sys/devices/system/cpu/online")
if err != nil || len(online) == 0 {
return 0
}
atom, _ := readCPUList(fsys, "sys/devices/cpu_atom/cpus")
n, err := physicalCores(fsys, online, atom)
if err != nil {
return 0
}
return n
}
func tuneFreePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
// tuneTrialer lance les essais d'une optimisation.
type tuneTrialer struct {
dir string
self string
lock *tuneLock
seq int
optIn bool
}
// prepare pose la configuration d'un essai (copie + adresse privée) dans un
// dossier neuf.
func (t *tuneTrialer) prepare(cfg map[string]string) (dir string, port int, err error) {
t.seq++
dir = filepath.Join(t.dir, fmt.Sprintf("e%02d", t.seq))
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", 0, err
}
if port, err = tuneFreePort(); err != nil {
return "", 0, err
}
c := copyCfg(cfg)
c["HOST"], c["PORT"] = "127.0.0.1", strconv.Itoa(port)
return dir, port, writeTuneTrialConfig(dir, c)
}
// dry : « loki serve » à blanc — la ligne de commande, rien de chargé.
func (t *tuneTrialer) dry(ctx context.Context, dir string) (tuneLaunch, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, t.self, "serve"))
cmd.Dir = LokiHome()
cmd.Env = append(os.Environ(), tuneTrialEnv+"="+dir, tuneDryRunEnv+"=1")
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(ansiRe.ReplaceAllString(string(out), ""))
if i := strings.LastIndex(msg, "\n"); i >= 0 {
msg = msg[i+1:]
}
if msg == "" {
msg = err.Error()
}
return tuneLaunch{}, errors.New(msg)
}
return readTuneLaunch(dir)
}
// tuneLoaded : un essai chargé et prêt.
type tuneLoaded struct {
eng benchEngine
nCtx int
slots int
build string
offloaded int
layers int
kv string
log string
}
// tuneProps lit n_ctx, total_slots et build_info du moteur.
func tuneProps(ctx context.Context, e benchEngine) (nCtx, slots int, build string, err error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
data, err := e.do(ctx, http.MethodGet, "/props", nil)
if err != nil {
return 0, 0, "", err
}
var p struct {
BuildInfo string `json:"build_info"`
TotalSlots int `json:"total_slots"`
NCtx int `json:"n_ctx"`
Default struct {
NCtx int `json:"n_ctx"`
} `json:"default_generation_settings"`
}
if err := json.Unmarshal(data, &p); err != nil {
return 0, 0, "", err
}
nCtx = p.Default.NCtx
if nCtx <= 0 {
nCtx = p.NCtx
}
return nCtx, p.TotalSlots, p.BuildInfo, nil
}
// load lance l'essai pour de vrai et attend qu'il réponde. La fonction rendue
// l'arrête (groupe entier), efface sa trace du verrou et attend que la VRAM
// soit rendue : à appeler en defer, quoi qu'il arrive.
func (t *tuneTrialer) load(ctx context.Context, dir string, port int, launch tuneLaunch) (*tuneLoaded, func(), error) {
logPath := filepath.Join(dir, "engine.log")
logf, err := os.Create(logPath)
if err != nil {
return nil, func() {}, err
}
cmd := exec.Command(t.self, "serve")
cmd.Dir = LokiHome()
cmd.Env = append(os.Environ(), tuneTrialEnv+"="+dir)
cmd.Stdout, cmd.Stderr = logf, logf
tuneTrialAttr(cmd)
if err := cmd.Start(); err != nil {
logf.Close()
return nil, func() {}, err
}
pid := cmd.Process.Pid
done := make(chan struct{})
go func() {
_ = cmd.Wait()
logf.Close()
close(done)
}()
proc := tuneProc{PID: pid, Exes: []string{t.self, launch.Bin}}
if start, _, ok := procIdentity(pid); ok {
proc.Start = start
}
t.lock.setTrial(&proc)
stop := func() {
before := gpuUsedMB(gpuStats())
tuneKillTree(pid, done)
t.lock.setTrial(nil)
if before > 0 {
gpuUsedSettled(before)
} else {
time.Sleep(2 * time.Second)
}
}
readLog := func() string {
b, _ := os.ReadFile(logPath)
return string(b)
}
e := benchEngine{base: fmt.Sprintf("http://127.0.0.1:%d", port), auth: tuneAuth(), client: http.DefaultClient}
deadline := time.Now().Add(tuneLoadTimeout)
hc := &http.Client{Timeout: 3 * time.Second}
for {
select {
case <-ctx.Done():
return nil, stop, ctx.Err()
case <-done:
return nil, stop, errors.New(tuneLoadFailure(readLog()))
case <-time.After(time.Second):
}
if resp, err := hc.Get(e.base + "/health"); err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
break
}
}
if time.Now().After(deadline) {
return nil, stop, fmt.Errorf("pas prêt après %s", tuneLoadTimeout)
}
}
nCtx, slots, build, err := tuneProps(ctx, e)
if err != nil {
return nil, stop, fmt.Errorf("/props illisible : %w", err)
}
log := readLog()
off, layers, kv := tuneLogFacts(log)
return &tuneLoaded{eng: e, nCtx: nCtx, slots: slots, build: build, offloaded: off, layers: layers, kv: kv, log: log}, stop, nil
}
// tuneBenchSetup : le bench du preset de l'essai — son échantillonnage et son
// raisonnement réels (une spéculation se juge sur le tirage du preset, pas en
// glouton), profondeur plafonnée comme celle de la référence.
func tuneBenchSetup(cfg map[string]string, argEnv map[string]string, cpuPlaced bool) benchSetup {
s := benchSetupFrom(cfg, argEnv)
s.learn = func(want, got string) { effortRemember(want, got) }
s.cpuPlaced = cpuPlaced
return s
}
// runTune déroule une optimisation. L'appelant fournit le suivi ; le verrou,
// l'arrêt et la relance du vrai moteur sont faits ici.
func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (*tuneResult, error) {
if opts.Budget <= 0 {
opts.Budget = tuneDefaultBudget
}
opts.Budget = min(opts.Budget, tuneMaxBudget)
tr.mu.Lock()
tr.started, tr.budget = time.Now(), opts.Budget
tr.mu.Unlock()
cfg := ReadConfig()
preset, content, err := tuneActivePreset()
if err != nil {
return nil, err
}
self, err := os.Executable()
if err != nil {
return nil, err
}
mainActive := engineNeedsStop()
lock, err := tuneLockAcquire(opts.Via, mainActive)
if err != nil {
return nil, err
}
defer lock.release()
// Le vrai moteur revient AVANT que le verrou ne soit rendu (les defer
// passent dans l'ordre inverse) : personne ne le relance par-dessus un essai.
defer func() {
if !mainActive {
return
}
lock.setPhase("relance")
tr.setPhase("relance du moteur principal")
if err := preflightEngine(); err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] relance impossible : %s\n", plainErr(err))
return
}
if err := serviceActionOS("start"); err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] relance du moteur : %s\n", plainErr(err))
}
gpuMonitor.invalidate()
}()
runDir := filepath.Join(LokiHome(), "tune", "run")
_ = os.RemoveAll(runDir)
if err := os.MkdirAll(runDir, 0o700); err != nil {
return nil, err
}
t := &tuneTrialer{dir: runDir, self: self, lock: lock, optIn: opts.OptIn}
if mainActive {
tr.setPhase("arrêt du moteur principal")
gpus := gpuStats()
before := gpuUsedMB(gpus)
if err := serviceActionOS("stop"); err != nil {
return nil, fmt.Errorf("arrêt du moteur : %s", plainErr(err))
}
asrShutdownVite()
if gpus != nil {
gpuUsedSettled(before)
}
gpuMonitor.invalidate()
}
res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg),
PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(),
Weights: tuneWeightsFrom(perfLog.snapshot())}
// Référence : la ligne composée telle quelle, la sonde de la machine.
tr.setPhase("référence : composition de la ligne de commande")
baseDir, basePort, err := t.prepare(cfg)
if err != nil {
return nil, err
}
baseLaunch, err := t.dry(ctx, baseDir)
if err != nil {
return nil, fmt.Errorf("la configuration active ne se lance pas : %s", err)
}
probe := baseLaunch.Probe
if probe == nil {
probe = &tuneProbe{}
}
smi := gpuStats()
env := tuneEnv{Help: probe.Help, GPUs: probe.GPUs, VRAMMiB: probe.VRAMMiB, ModelBytes: probe.ModelBytes,
MoE: probe.MoE, PhysCores: tunePhysCores(probe), SMI: smi != nil, ArgEnv: probe.ArgEnv,
UserEnv: probe.UserEnv, QueuesEnv: probe.QueuesEnv, Placement: opts.Placement, OptIn: opts.OptIn}
visible := tuneVisibleGPUs(cfg)
if env.VRAMMiB == 0 && smi != nil {
for i, g := range smi {
if len(visible) == 0 || containsInt(visible, i) {
env.VRAMMiB += int64(g.Total)
}
}
}
if env.GPUs == 0 && smi != nil {
env.GPUs = len(smi)
if len(visible) > 0 {
env.GPUs = len(visible)
}
}
extra := splitArgs(cfg["EXTRA_ARGS"])
kt, vt, _ := effectiveKVTypes(cfg, extra, env.ArgEnv)
if label, quant := benchKVLabel(kt, vt); quant {
res.Notes = append(res.Notes, "cache KV "+label+" quantifié : zone grise, conservé tel quel (jamais modifié par l'optimiseur)")
}
if !env.SMI {
res.Notes = append(res.Notes, "VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés")
}
if _, total := ramUsageMB(); total > 0 && env.ModelBytes > int64(total)<<20 {
res.Notes = append(res.Notes, "modèle plus gros que la RAM : chaque essai le relit depuis le disque — prévoir un budget "+
"plus long, ou ne garder que quelques étapes")
}
reserve := tuneTightMiB
if strings.TrimSpace(cfg["MMPROJ"]) != "" {
reserve += tuneVisionReserveMiB
}
cpuPlaced := tensorOverride(extra, env.ArgEnv) != ""
// La référence se mesure deux fois : son écart entre passages est le bruit
// que tout gain doit dépasser.
tr.setPhase("référence : chargement")
bi := tr.put(-1, tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "en cours", Headroom: -1})
t0 := time.Now()
base, stop, err := t.load(ctx, baseDir, basePort, baseLaunch)
if err != nil {
stop()
return nil, fmt.Errorf("la référence ne charge pas : %s", err)
}
res.Engine, res.NCtx, res.Slots = base.build, base.nCtx, base.slots
if m := reBuildInfo.FindStringSubmatch(base.build); m != nil {
res.EngineBuild, _ = strconv.Atoi(m[1])
}
// Une profondeur pour tous : celle que la référence peut tenir avec le plus
// gros préchauffage essayé (2 × 4096 jetons, au plus un quart du contexte).
warmMax := max(min(2*4096, base.nCtx/4), 64) + benchWarmGen
res.Depth, _ = benchDepthFor(base.nCtx, cpuPlaced, warmMax)
bopts := benchOpts{Mode: benchModeFull, Prompt: 2000, Predict: 300, Depth: res.Depth}
corp := defaultBenchCorpora()
measure := func(cfg map[string]string, l *tuneLoaded, n int, phase string) ([]tuneRun, error) {
var runs []tuneRun
for i := 0; i < n; i++ {
tr.setPhase("%s : mesure %d/%d", phase, i+1, n)
r, err := benchRun(ctx, l.eng, bopts, tuneBenchSetup(cfg, env.ArgEnv, cpuPlaced), corp, nil)
if err != nil {
return runs, err
}
run, err := tuneRunFrom(r, res.Weights)
if err != nil {
return runs, err
}
runs = append(runs, run)
}
return runs, nil
}
headroom := func() (int, bool) {
if !env.SMI {
return -1, false
}
free, ok := tuneHeadroom(gpuStats(), visible)
if !ok {
return -1, false
}
return free, free < reserve
}
baseRuns, err := measure(cfg, base, 2, "référence")
hr, _ := headroom()
stop()
if err != nil {
return nil, fmt.Errorf("mesure de la référence : %s", err)
}
bt := tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "mesuré", Runs: baseRuns,
TurnSec: turnMean(baseRuns), Headroom: hr, Sec: time.Since(t0).Seconds()}
if base.offloaded >= 0 {
bt.Offloaded = fmt.Sprintf("%d/%d", base.offloaded, base.layers)
}
res.Baseline = bt
tr.put(bi, bt)
if hr >= 0 && hr < reserve {
res.Notes = append(res.Notes, fmt.Sprintf("la configuration actuelle laisse déjà moins de %d Mio libres sur une carte", reserve))
}
best, bestRuns, bestLabel := cfg, baseRuns, ""
durations := []float64{bt.Sec}
measured := map[string]string{tuneLaunchKey(baseLaunch): bt.Label}
deadline := tr.started.Add(opts.Budget)
stages:
for si, st := range tuneStages {
if !tuneStageWanted(st.Name, opts.Stages) {
res.Skipped = append(res.Skipped, st.Name+" : non demandé")
continue
}
cands, why := st.Gen(best, env)
if why != "" {
res.Skipped = append(res.Skipped, st.Name+" : "+why)
continue
}
for ci, c := range cands {
// Ce qui reste à faire, pour l'estimation : les essais restants de
// l'étape, plus ceux des étapes suivantes vus depuis la meilleure
// configuration du moment.
left := len(cands) - ci
for _, nx := range tuneStages[si+1:] {
if !tuneStageWanted(nx.Name, opts.Stages) {
continue
}
more, _ := nx.Gen(best, env)
left += len(more)
}
avg := tuneMean(durations)
tr.mu.Lock()
tr.avgSec, tr.left = avg, left
tr.mu.Unlock()
if ctx.Err() != nil {
res.Partial = "annulé"
break stages
}
if time.Until(deadline).Seconds() < avg {
res.Partial = fmt.Sprintf("budget de %s atteint : %d essai(s) non faits", opts.Budget.Round(time.Minute), left)
break stages
}
trial := tuneTrial{Stage: c.Stage, Label: c.Label, Status: "en cours", Headroom: -1,
Changes: tuneDiff(cfg, c.Cfg)}
idx := tr.put(-1, trial)
finish := func(status, why string) {
trial.Status, trial.Why = status, why
tr.put(idx, trial)
res.Trials = append(res.Trials, trial)
}
if err := tuneCfgCheck(cfg, c.Cfg, opts.OptIn); err != nil {
finish("écarté", "dénature : "+err.Error())
continue
}
tr.setPhase("%s — %s : composition", st.Name, c.Label)
dir, port, err := t.prepare(c.Cfg)
if err != nil {
finish("échec", err.Error())
continue
}
launch, err := t.dry(ctx, dir)
if err != nil {
if ctx.Err() != nil {
finish("échec", "annulé")
res.Partial = "annulé"
break stages
}
finish("écarté", "refusé au lancement : "+err.Error())
continue
}
if why := tuneDenature(baseLaunch, launch, opts.OptIn); why != "" {
finish("écarté", "dénature : "+why)
continue
}
key := tuneLaunchKey(launch)
if prev, ok := measured[key]; ok {
finish("doublon", "même ligne de commande que « "+prev+" »")
continue
}
measured[key] = c.Label
t1 := time.Now()
tr.setPhase("%s — %s : chargement", st.Name, c.Label)
l, stop, err := t.load(ctx, dir, port, launch)
if err != nil {
stop()
if ctx.Err() != nil {
finish("échec", "annulé")
res.Partial = "annulé"
break stages
}
finish("échec", err.Error())
durations = append(durations, time.Since(t1).Seconds())
continue
}
trial.Sec = time.Since(t1).Seconds()
if l.offloaded >= 0 {
trial.Offloaded = fmt.Sprintf("%d/%d", l.offloaded, l.layers)
}
// Contre-vérification à l'exécution : même contexte, mêmes slots,
// mêmes types de cache que la référence.
switch {
case l.nCtx != base.nCtx || l.slots != base.slots:
stop()
finish("écarté", fmt.Sprintf("dénature : contexte %d × %d slot(s) au lieu de %d × %d", l.nCtx, l.slots, base.nCtx, base.slots))
continue
case base.kv != "" && l.kv != "" && l.kv != base.kv:
stop()
finish("écarté", "dénature : cache KV "+l.kv+" au lieu de "+base.kv)
continue
}
fitRegressed := base.offloaded >= 0 && l.offloaded >= 0 && l.offloaded < base.offloaded
runs, err := measure(c.Cfg, l, 1, st.Name+" — "+c.Label)
if err == nil && len(runs) == 1 && tuneWorthRepeat(turnSecs(bestRuns), runs[0].TurnSec) && !fitRegressed {
var more []tuneRun
more, err = measure(c.Cfg, l, 1, st.Name+" — "+c.Label+" (confirmation)")
runs = append(runs, more...)
}
hr, tight := headroom()
stop()
trial.Sec = time.Since(t1).Seconds()
durations = append(durations, trial.Sec)
trial.Runs, trial.Headroom = runs, hr
if err != nil {
if ctx.Err() != nil {
finish("échec", "annulé")
res.Partial = "annulé"
break stages
}
finish("échec", "mesure : "+err.Error())
continue
}
trial.TurnSec = turnMean(runs)
gain, need, wins := tuneWins(turnSecs(bestRuns), turnSecs(runs))
trial.Gain = gain
proseBest, proseCand := proseMean(bestRuns), proseMean(runs)
switch {
case fitRegressed:
finish("écarté", fmt.Sprintf("fit en recul : %d couches déportées au lieu de %d (performance)", l.offloaded, base.offloaded))
case tight:
finish("écarté", fmt.Sprintf("trop juste : %d Mio libres sur une carte (< %d)", hr, reserve))
case proseBest > 0 && proseCand < 0.95*proseBest:
finish("mesuré", fmt.Sprintf("decode en prose plus lent de %.0f %%", (1-proseCand/proseBest)*100))
case wins:
best, bestRuns, bestLabel = c.Cfg, runs, c.Label
if c.Stage == "placement" {
res.Placement = true
}
finish("retenu", fmt.Sprintf("−%.1f %% par tour (seuil %.1f %%)", gain*100, need*100))
case len(runs) < 2 && gain > 0:
finish("mesuré", fmt.Sprintf("−%.1f %%, sous le seuil de %.0f %%", gain*100, tuneMinGain*100))
default:
delta := fmt.Sprintf("%+.1f %%", -gain*100)
if gain*1000 > -0.5 && gain*1000 < 0.5 {
delta = "≈ 0 %"
}
finish("mesuré", delta+fmt.Sprintf(" (seuil %.1f %%)", need*100))
}
}
}
tr.mu.Lock()
tr.left = 0
tr.mu.Unlock()
res.Best = bestLabel
if bestLabel != "" {
res.Gain = (turnMean(baseRuns) - turnMean(bestRuns)) / turnMean(baseRuns)
res.Changes = tuneDiff(cfg, best)
res.Set, res.Old = map[string]string{}, map[string]string{}
for k := range keysOf(cfg, best) {
if cfg[k] != best[k] {
res.Set[k], res.Old[k] = best[k], cfg[k]
}
}
}
if ctx.Err() != nil && res.Partial == "" {
res.Partial = "annulé"
}
saveTuneResult(res)
return res, nil
}
func turnSecs(r []tuneRun) []float64 {
out := make([]float64, len(r))
for i, x := range r {
out[i] = x.TurnSec
}
return out
}
func turnMean(r []tuneRun) float64 { return tuneMean(turnSecs(r)) }
func proseMean(r []tuneRun) float64 {
v := make([]float64, len(r))
for i, x := range r {
v[i] = x.ProseTG
}
return tuneMean(v)
}
func keysOf(ms ...map[string]string) map[string]bool {
out := map[string]bool{}
for _, m := range ms {
for k := range m {
out[k] = true
}
}
return out
}
// --- résultats enregistrés -------------------------------------------------------
// saveTuneResult garde le dernier résultat par preset, avec l'empreinte et le
// build du moteur : après une mise à jour du moteur, l'interface propose de
// relancer l'optimisation.
func saveTuneResult(res *tuneResult) {
m := map[string]tuneResult{}
getJSON(bkState, "tune_results", &m)
if m == nil {
m = map[string]tuneResult{}
}
m[res.PresetID] = *res
_ = putJSON(bkState, "tune_results", m)
}
func loadTuneResult(id string) (*tuneResult, bool) {
m := map[string]tuneResult{}
if !getJSON(bkState, "tune_results", &m) {
return nil, false
}
r, ok := m[id]
if !ok {
return nil, false
}
return &r, true
}
// --- application ------------------------------------------------------------------
// tuneApplyTarget : où écrire le résultat.
const (
tuneApplyCopy = "copy" // une copie du preset, non activée
tuneApplyPreset = "preset" // le preset mesuré lui-même, réappliqué et vérifié
)
// tunePlanApply prépare la réécriture sans rien écrire : le contenu du preset
// réglé et le diff des clés. Refusé si le preset a changé depuis la mesure.
func tunePlanApply(res *tuneResult) (content, patched string, err error) {
if res == nil || len(res.Set) == 0 {
return "", "", errors.New("rien à appliquer : la configuration actuelle reste la meilleure")
}
content, err = ReadPreset(res.PresetID)
if err != nil {
return "", "", fmt.Errorf("preset « %s » introuvable : %w", res.PresetName, err)
}
if presetFingerprint([]byte(content)) != res.PresetFP {
return "", "", errors.New("le preset a changé depuis la mesure : relance l'optimisation")
}
base := parseEnv(content)
win := copyCfg(base)
for k, v := range res.Set {
if v == "" {
delete(win, k)
} else {
win[k] = v
}
}
if err := tuneCfgCheck(base, win, res.OptIn); err != nil {
return "", "", err
}
patched, err = tunePatchPreset(content, base, win)
return content, patched, err
}
// tuneApply écrit le résultat : dans une COPIE du preset (rien d'autre ne
// bouge), ou dans le preset lui-même — il doit être encore actif. Dans ce cas
// le moteur redémarre sur la nouvelle version, qui doit répondre, garder son
// contexte et ses slots, et passer une sonde (un prompt à la profondeur mesurée
// puis un decode) ; sinon l'ancienne version est rétablie. L'appelant a la
// confirmation de l'utilisateur.
func tuneApply(ctx context.Context, res *tuneResult, target string, say func(string)) (string, error) {
content, patched, err := tunePlanApply(res)
if err != nil {
return "", err
}
switch target {
case tuneApplyCopy:
name := res.PresetName + " (optimisé)"
id, err := SavePreset("", name, patched)
if err != nil {
return "", err
}
return fmt.Sprintf("copie « %s » créée (%s) — bascule dessus pour l'utiliser", name, id), nil
case tuneApplyPreset:
default:
return "", fmt.Errorf("cible inconnue : %s", target)
}
if externalActive() {
return "", errors.New("le preset actif est une API externe")
}
cur := ReadConfig()
if presetFingerprint([]byte(content)) != configFingerprint(cur) {
return "", errors.New("ce preset n'est plus celui en service : bascule dessus, ou enregistre le résultat dans une copie")
}
if configFingerprint(cur) != res.BaseFP {
return "", errors.New("la configuration active a changé depuis la mesure : relance l'optimisation")
}
lock, err := tuneLockAcquire("apply", serviceIsActive())
if err != nil {
return "", err
}
defer lock.release()
lock.setPhase("application")
backup := filepath.Join(LokiHome(), "tune", "backup", fmt.Sprintf("%s-%d.env", res.PresetID, time.Now().Unix()))
_ = os.MkdirAll(filepath.Dir(backup), 0o700)
if err := os.WriteFile(backup, []byte(content), 0o600); err != nil {
return "", fmt.Errorf("sauvegarde du preset : %w", err)
}
revert := func(why string) (string, error) {
say("échec (" + why + ") : retour à l'ancienne version")
if _, _, err := SavePresetApplying(res.PresetID, res.PresetName, content); err != nil {
return "", fmt.Errorf("%s ; RETOUR IMPOSSIBLE (%v) — l'ancienne version est dans %s", why, err, backup)
}
if err := tuneSvc("restart"); err != nil {
return "", fmt.Errorf("%s ; ancienne version remise, redémarrage : %s", why, plainErr(err))
}
return "", fmt.Errorf("%s : ancienne version rétablie", why)
}
say("écriture du preset (sauvegarde : " + backup + ")")
_, applied, err := SavePresetApplying(res.PresetID, res.PresetName, patched)
if err != nil {
return revert("écriture : " + err.Error())
}
if !applied {
return revert("le preset n'a pas été appliqué à la configuration active")
}
// Même configuration que l'essai retenu, à l'empreinte près : même
// composition de la ligne de commande (buildServeArgs) que mesurée.
if why := tuneAppliedMismatch(ReadConfig(), res); why != "" {
return revert(why)
}
say("redémarrage du moteur")
if err := tuneSvc("restart"); err != nil {
return revert("redémarrage : " + plainErr(err))
}
if err := tuneProbeFn(ctx, res, say); err != nil {
return revert(err.Error())
}
return "preset « " + res.PresetName + " » optimisé et vérifié", nil
}
// tuneSvc et tuneProbeFn : serviceActionOS et tunePostApplyProbe, remplaçables
// dans les tests (application puis retour arrière sans vrai moteur).
var (
tuneSvc = serviceActionOS
tuneProbeFn = tunePostApplyProbe
)
// tuneAppliedMismatch : la configuration active après application doit être
// EXACTEMENT celle de l'essai retenu — la référence mesurée plus les clés
// réglées. Remises à leur valeur de référence, on doit retrouver l'empreinte
// mesurée. Même configuration, même composition (buildServeArgs) : le moteur
// relancé est bien celui qui a gagné. "" = conforme.
func tuneAppliedMismatch(now map[string]string, res *tuneResult) string {
back := copyCfg(now)
for k, v := range res.Set {
if now[k] != v {
return "la configuration appliquée diffère de l'essai retenu sur " + k
}
if old := res.Old[k]; old == "" {
delete(back, k)
} else {
back[k] = old
}
}
if configFingerprint(back) != res.BaseFP {
return "la configuration a changé ailleurs que sur les clés réglées depuis la mesure"
}
return ""
}
// tunePostApplyProbe : le moteur redémarré répond, garde le contexte et les
// slots mesurés, et tient un prompt à la profondeur de la mesure suivi d'un
// decode — ce qu'un /health seul ne montre pas (un OOM au premier long prompt).
func tunePostApplyProbe(ctx context.Context, res *tuneResult, say func(string)) error {
say("attente du moteur")
e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient}
deadline := time.Now().Add(tuneLoadTimeout)
for !healthCheck() {
if time.Now().After(deadline) {
return fmt.Errorf("le moteur ne répond pas après %s", tuneLoadTimeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
nCtx, slots, _, err := tuneProps(ctx, e)
if err != nil {
return fmt.Errorf("/props illisible : %w", err)
}
if nCtx != res.NCtx || slots != res.Slots {
return fmt.Errorf("contexte %d × %d slot(s) au lieu de %d × %d", nCtx, slots, res.NCtx, res.Slots)
}
if res.Depth <= 0 {
return nil
}
say(fmt.Sprintf("sonde : prompt de %d jetons puis decode", res.Depth))
cfg := ReadConfig()
s := tuneBenchSetup(cfg, nil, false)
code := defaultBenchCorpora().code
sample, _ := benchSlice(code, 0, benchSampleBytes)
text, _ := benchSlice(code, 0, int(float64(res.Depth)*e.bytesPerToken(ctx, sample)))
defer engineSideJob()()
if _, err := s.ask(ctx, e, []Message{{Role: "user", Content: text + "\n\n" + benchAskFirst}}, 64, false); err != nil {
return fmt.Errorf("sonde : %w", err)
}
return nil
}