Files
Loki/internal/loki/backend_tune_run.go
T
MichaelandClaude Opus 5.5 4a9a5a1712 Moteur Strata : un second moteur, choisi par preset (ENGINE=strata)
Strata (github.com/Niko1221/Strata, MIT) fait tourner Qwen3.8 Flash Next
(MoE 125 B) sur une carte de joueur en répartissant les experts entre VRAM,
RAM et disque. Intégration reprise d'AJEAN (backend_moe.go, « AJEAN MoE
1.0 ») : paquet figé de la release moe-v1.0, vérifié par SHA-256.

- backend_strata.go : détection de la machine, quant conseillé, installation
  en tâche (même suivi que llama.cpp), preset ENGINE=strata, lancement du
  serveur de Strata par `loki serve`, réglages d'un modèle installé
- pré-vol, vision, liste des presets, journal de chargement : Strata reconnu
- proxy OpenAI et relais : Host local pour Strata (il refuse un Host inconnu)
- benchmark et optimiseur refusés sur Strata (protocole propre à llama-server)
- UI : ligne Strata dans Paramètres → Moteur (visible aussi avec le moteur de
  l'image), fenêtre d'installation et de réglages, moteur actif affiché
- image : python3 + python3-venv ; compose : memlock illimité

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:03:22 +02:00

1107 lines
38 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 strataActive() {
return errors.New("optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre")
}
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
}