mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 12:16:21 +02:00
L'anneau des complétions (perf_log.go) est porté en Rust (src/perfring.rs) : perte de cache contre la complétion précédente de la même discussion et de la même nature, ce qui s'est intercalé, seuil d'alerte du preset (dans l'ordre du lancement, 2048 sur un modèle hybride), ligne [perf], résumé de /api/perf/summary. ChatShared le porte : tours, vérifications, résumés de compaction et préchauffage de la façade y rangent leurs complétions sans aller-retour au Go. Côté Go, quand la façade tient la conversation, perfRecord range chez elle (/internal/facade/v1/perf/record) et perfSnapshot relit son anneau (optimiseur) ; /api/perf/summary lui est remis. Façade injoignable : l'anneau du Go prend le relais. La route /internal/v1/turn/perf, plus appelée, est retirée. Parité : journal tiré du Go (parity_perf_test.go → tests/parity_perf/ perf.json : 14 complétions, résumé, onze seuils), rejoué par src/perfring/tests.rs ; deux tours qui perdent le cache (tests/lot6.rs), l'API de l'anneau pour le Go, et TestFacadePerfAnneau côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
1107 lines
38 KiB
Go
1107 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 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(perfSnapshot())}
|
||
|
||
// 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
|
||
}
|