Files
Loki/internal/loki/backend_tune_test.go
T
MichaelandClaude Opus 5.5 5b834d54b2 Optimiseur : la reprise toutes les 30 s ne prend plus un verrou vivant pour périmé
Depuis que le processus web refait la reprise des optimisations
interrompues toutes les 30 s, elle tourne pendant que ce même processus
prend et rend ses propres verrous (bouton « Optimiser… », application d'un
résultat). Trois fenêtres lui faisaient écarter un verrou bien vivant : le
fichier créé avant l'inscription du verrou comme nôtre, l'inscription
retirée avant le fichier (sous Windows, un fichier qu'un autre lit ne se
supprime pas), et le renommage d'un verrou neuf pris entre la lecture et le
ménage. En phase « application », le preset tout juste vérifié était alors
défait, et le moteur relancé.

- Inscription avant la création du fichier, retrait après lui ; un
  identifiant de prise (nonce) distingue deux verrous du même processus dans
  la même seconde.
- Au moment de le rendre, le verrou est réécrit neutre (rien à défaire,
  rien à relancer, aucun essai), puis retiré avec quelques essais.
- Le ménage relit ce qu'il vient de déplacer : un autre verrou, ou un verrou
  vivant, est remis en place par un lien (jamais par-dessus un verrou neuf).
- La relance d'après reprise passe par serviceAction : une optimisation
  neuve qui a pris le verrou entre-temps garde le moteur arrêté.
- Test : 60 prises et rendus en phase « application » sous une reprise en
  boucle, aucune relance ; échoue sur l'ancien code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:24:20 +02:00

1003 lines
36 KiB
Go

package loki
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
)
// Aide d'un moteur récent, réduite à ce que lisent les tests de capacité.
const tuneTestHelp = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number, 'auto', or 'all'
-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory
-fitt, --fit-target MiB0,MiB1,... target margin per device for --fit
--spec-type [none,draft-simple,draft-mtp,ngram-mod]
--spec-draft-n-max N
-bs, --backend-sampling
--load-mode {auto,none,mmap,mlock,mmap+mlock,dio}
`
// Le découpage avec positions doit donner exactement les jetons de splitArgs :
// c'est ce qui garantit qu'une réécriture ne touche qu'aux jetons visés.
func TestTuneArgSpansMatchSplitArgs(t *testing.T) {
for _, s := range []string{
"", " ", "-b 2048", `--chat-template-file "/mes modèles/tpl.jinja" -ub 512`,
`-ot 'blk\.(1[0-9])\.ffn_.*_exps\.=CPU' --n-cpu-moe 40`, `"" -x`, `""x y`, `a""b`, `'a b'c "d"`,
"--cache-type-k q8_0\t--flash-attn on\n-t 8", `"non fermé -b 4`, `x"y z"w -ub=1024`,
} {
var got []string
for _, sp := range argSpans(s) {
got = append(got, sp.val)
}
want := splitArgs(s)
if len(got) == 0 && len(want) == 0 {
continue
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%q : argSpans %q, splitArgs %q", s, got, want)
}
}
}
// Les jetons hors liste blanche ressortent à l'octet près, guillemets compris,
// quelle que soit la réécriture.
func TestTuneExtraEditsKeepOtherTokens(t *testing.T) {
keep := []string{"--cache-type-k q8_0", "--flash-attn on", `--chat-template-file "/a b/t.jinja"`, "-ctv q8_0"}
extra := `-ot "blk\.([0-9]+)\.ffn_.*_exps\.=CPU,per_layer_token_embd=CPU" --cache-type-k q8_0 --n-cpu-moe 40 ` +
`--flash-attn on -ub 512 --chat-template-file "/a b/t.jinja" -ts 0.9,0.1 -ctv q8_0 -cmoe`
edits := map[string]string{
"set -ub": tuneExtraSet(extra, knobUBatch.flags, "2048"),
"drop -ub": tuneExtraSet(extra, knobUBatch.flags, ""),
"add -t": tuneExtraSet(extra, knobThreads.flags, "7"),
"drop -ts": tuneExtraDrop(extra, 1, "-ts", "--tensor-split"),
"add switch": tuneExtraAddSwitch(extra, "--backend-sampling"),
"drop expert": func() string { s, _ := tuneExtraDropExperts(extra); return s }(),
}
for name, out := range edits {
for _, k := range keep {
if !strings.Contains(out, k) {
t.Errorf("%s : %q perdu dans %q", name, k, out)
}
}
if err := tuneCfgCheck(map[string]string{"EXTRA_ARGS": extra}, map[string]string{"EXTRA_ARGS": out}, true); err != nil {
t.Errorf("%s : %v (%q)", name, err, out)
}
}
if out := edits["set -ub"]; !strings.Contains(out, "-ub 2048") || strings.Contains(out, "-ub 512") {
t.Errorf("-ub non réglé sur place : %q", out)
}
out, removed := tuneExtraDropExperts(extra)
if strings.Contains(out, "exps") || strings.Contains(out, "--n-cpu-moe") || strings.Contains(out, "-cmoe") {
t.Errorf("experts encore placés à la main : %q", out)
}
if !strings.Contains(out, "-ot per_layer_token_embd=CPU") || len(removed) != 3 {
t.Errorf("élément non expert du -ot perdu, ou retraits mal comptés : %q %q", out, removed)
}
if got := tuneExtraSet("-ub=512 -b 4096", knobUBatch.flags, "1024"); got != "-ub=1024 -b 4096" {
t.Errorf("forme -ub= : %q", got)
}
}
// Un drapeau interdit (cache KV, flash-attn, contexte…) ne fait jamais partie de
// la liste blanche, et une clé intouchable n'est jamais réglable.
func TestTuneWhitelistDisjoint(t *testing.T) {
for _, f := range tuneForbiddenFlags {
if tuneFlagFor(f, true) != nil {
t.Errorf("%s est à la fois interdit et réglable", f)
}
}
for _, k := range tuneNeverKeys {
if tuneKeys[k] || tuneOptInKeys[k] {
t.Errorf("%s est à la fois intouchable et réglable", k)
}
}
base := map[string]string{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0"}
for _, c := range []map[string]string{
{"CTX": "65536", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0"},
{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k f16"},
{"CTX": "32768", "EXTRA_ARGS": "-ub 512"},
{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "KV_TYPE": "q4_0"},
{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "SPEC": "auto"}, // opt-in non coché
} {
if tuneCfgCheck(base, c, false) == nil {
t.Errorf("changement interdit accepté : %v", c)
}
}
if err := tuneCfgCheck(base, map[string]string{"CTX": "32768", "EXTRA_ARGS": "-ub 512 --cache-type-k q8_0", "SPEC": "auto"}, true); err != nil {
t.Errorf("SPEC refusé avec l'opt-in : %v", err)
}
}
func tuneRichCfg() map[string]string {
return map[string]string{
"MODEL": "big.gguf", "CTX": "65536", "KV_TYPE": "q8_0", "MMPROJ": "mmproj.gguf", "REASONING": "on",
"REASONING_BUDGET": "-1", "TEMP": "0.6", "TOP_K": "20", "PARALLEL": "1", "SPEC": "mtp",
"EXTRA_ARGS": `-ot "blk\.([0-9]+)\.ffn_.*_exps\.=CPU" --n-cpu-moe 40 --cache-type-k q8_0 --flash-attn on -ub 512 -t 8 -ts 0.6,0.4`,
}
}
func tuneRichEnv() tuneEnv {
return tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 80 << 30, MoE: true, PhysCores: 8,
SMI: true, Placement: true, OptIn: true}
}
// La grille : chaque essai ne change que des clés et des drapeaux permis, ne
// touche à aucune clé intouchable, et garde les drapeaux interdits à l'octet.
func TestTuneGridRespectsNeverTouch(t *testing.T) {
base := tuneRichCfg()
env := tuneRichEnv()
total := 0
for _, st := range tuneStages {
cands, why := st.Gen(base, env)
if why != "" {
continue
}
for _, c := range cands {
total++
if err := tuneCfgCheck(base, c.Cfg, env.OptIn); err != nil {
t.Errorf("%s / %s : %v", st.Name, c.Label, err)
}
for _, k := range tuneNeverKeys {
if c.Cfg[k] != base[k] {
t.Errorf("%s / %s : %s modifié (%q → %q)", st.Name, c.Label, k, base[k], c.Cfg[k])
}
}
for _, k := range []string{"--cache-type-k q8_0", "--flash-attn on"} {
if !strings.Contains(c.Cfg["EXTRA_ARGS"], k) {
t.Errorf("%s / %s : %q perdu", st.Name, c.Label, k)
}
}
}
}
if total == 0 {
t.Fatal("aucun essai généré")
}
}
// Chaque axe n'existe que si le moteur, la machine et l'utilisateur le
// permettent.
func TestTuneGridGating(t *testing.T) {
base := tuneRichCfg()
gen := func(name string, env tuneEnv) ([]tuneCand, string) {
for _, st := range tuneStages {
if st.Name == name {
return st.Gen(base, env)
}
}
t.Fatalf("étape %s inconnue", name)
return nil, ""
}
env := tuneRichEnv()
env.Placement = false
if _, why := gen("placement", env); why == "" {
t.Error("placement essayé sans « inclure le placement »")
}
env = tuneRichEnv()
env.Help = strings.ReplaceAll(tuneTestHelp, "--fit", "--nofit")
if _, why := gen("placement", env); why == "" {
t.Error("placement essayé sur un moteur sans --fit")
}
env = tuneRichEnv()
if c, why := gen("placement", env); why != "" || len(c) != 1 {
t.Fatalf("placement attendu : %q", why)
}
// Marges --fit : refusées tant que le placement manuel coupe --fit, ou sans
// --fit-target dans l'aide.
if _, why := gen("marges", env); why == "" {
t.Error("marges essayées alors que -ot / -ts coupent --fit")
}
plain := map[string]string{"MODEL": "m.gguf", "CTX": "32768"}
for _, st := range tuneStages {
if st.Name != "marges" {
continue
}
if c, why := st.Gen(plain, env); why != "" || len(c) != 3 {
t.Errorf("marges sur 2 cartes : %d essai(s), %q", len(c), why)
}
e2 := env
e2.Help = strings.ReplaceAll(tuneTestHelp, "--fit-target", "--fit-x")
if _, why := st.Gen(plain, e2); why == "" {
t.Error("marges essayées sans --fit-target")
}
e2 = env
e2.SMI = false
if _, why := st.Gen(plain, e2); why == "" {
t.Error("marges essayées sans mesure de VRAM")
}
}
// Lots : sans mesure de VRAM, jamais de micro-lot plus grand.
env = tuneRichEnv()
env.SMI = false
cands, _ := gen("lots", env)
for _, c := range cands {
if serveUBatch(c.Cfg, splitArgs(c.Cfg["EXTRA_ARGS"])) > 512 {
t.Errorf("micro-lot monté sans mesure de VRAM : %s", c.Label)
}
}
// -ub dans EXTRA_ARGS : réglé là, jamais par une clé qu'il écraserait.
cands, _ = gen("lots", tuneRichEnv())
for _, c := range cands {
if c.Cfg["UBATCH"] != "" {
t.Errorf("%s : UBATCH posé alors qu'EXTRA_ARGS fixe -ub", c.Label)
}
}
// Deux cartes tout GPU : le lot reste plus grand que le micro-lot.
dense := map[string]string{"MODEL": "m.gguf"}
env2 := tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 10 << 30, SMI: true}
for _, st := range tuneStages {
if st.Name != "lots" {
continue
}
cs, _ := st.Gen(dense, env2)
for _, c := range cs {
b, _ := strconv.Atoi(knobBatch.get(c.Cfg))
ub, _ := strconv.Atoi(knobUBatch.get(c.Cfg))
if b <= ub {
t.Errorf("%s : lot pas plus grand que le micro-lot sur 2 cartes", c.Label)
}
}
}
for _, name := range []string{"threads", "délestage"} {
for _, st := range tuneStages {
if st.Name == name {
if _, why := st.Gen(dense, env2); why == "" {
t.Errorf("%s essayé alors que tous les poids sont sur GPU", name)
}
}
}
}
// Opt-in : rien sans la case ; avec, --backend-sampling et SPEC_N_MAX.
env = tuneRichEnv()
env.OptIn = false
if _, why := gen("opt-in", env); why == "" {
t.Error("opt-in essayé sans la case")
}
cands, _ = gen("opt-in", tuneRichEnv())
labels := map[string]bool{}
for _, c := range cands {
labels[c.Label] = true
}
for _, want := range []string{"--backend-sampling", "SPEC_N_MAX=2", "SPEC_N_MAX=4", "CUDA_GRAPH_OPT=on"} {
if !labels[want] {
t.Errorf("opt-in : %s manquant (%v)", want, labels)
}
}
// Threads : « omis » = aucun -t, jamais THREADS=0.
cands, _ = gen("threads", tuneRichEnv())
found := false
for _, c := range cands {
if c.Label == "threads omis (cœurs physiques)" {
found = true
if hasAnyFlag(splitArgs(c.Cfg["EXTRA_ARGS"]), "-t") || c.Cfg["THREADS"] != "" {
t.Errorf("threads omis mais -t ou THREADS encore là : %v", c.Cfg)
}
}
}
if !found {
t.Error("candidat « threads omis » absent")
}
}
// La comparaison des lignes de commande : un réglage permis passe, un drapeau
// de fidélité ou une variable non réglée changés sont « dénature ».
func TestTuneDenature(t *testing.T) {
base := tuneLaunch{Args: []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "2048", "-ub", "512",
"--host", "127.0.0.1", "--port", "41000", "--parallel", "1", "-ctk", "q8_0", "-ot", "exps=CPU,per_layer_token_embd=CPU",
"--cache-ram", "8192", "--flash-attn", "on"},
Env: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_SCALE_LAUNCH_QUEUES": "4x"}}
ok := base
ok.Args = []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "4096", "-ub", "2048",
"--host", "127.0.0.1", "--port", "41001", "--parallel", "1", "-ctk", "q8_0", "-ot", "per_layer_token_embd=CPU",
"--cache-ram", "12000", "--flash-attn", "on", "-t", "7"}
ok.Env = map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}
if why := tuneDenature(base, ok, false); why != "" {
t.Errorf("réglages permis jugés dénaturants : %s", why)
}
for name, mut := range map[string]func(l *tuneLaunch){
"ctx": func(l *tuneLaunch) { l.Args[4] = "32768" },
"kv": func(l *tuneLaunch) { l.Args[16] = "f16" },
"ot": func(l *tuneLaunch) { l.Args[18] = "exps=CPU" },
"fa": func(l *tuneLaunch) { l.Args[len(l.Args)-3] = "off" },
"slots": func(l *tuneLaunch) { l.Args[14] = "2" },
"cuda": func(l *tuneLaunch) { l.Env["CUDA_VISIBLE_DEVICES"] = "0,1" },
"graphes": func(l *tuneLaunch) { l.Env["GGML_CUDA_GRAPH_OPT"] = "1" },
"spec": func(l *tuneLaunch) { l.Args = append(l.Args, "--spec-draft-n-max", "4") },
} {
c := ok
c.Args = append([]string(nil), ok.Args...)
c.Env = map[string]string{}
for k, v := range ok.Env {
c.Env[k] = v
}
mut(&c)
if tuneDenature(base, c, false) == "" {
t.Errorf("%s : changement non vu", name)
}
}
// Opt-in coché : la spéculation et les graphes CUDA deviennent réglables.
c := ok
c.Args = append(append([]string(nil), ok.Args...), "--spec-draft-n-max", "4")
c.Env = map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_CUDA_GRAPH_OPT": "1"}
if why := tuneDenature(base, c, true); why != "" {
t.Errorf("opt-in : %s", why)
}
}
func TestTuneLogFacts(t *testing.T) {
log := "load_tensors: offloaded 40/49 layers to GPU\n" +
"llama_kv_cache: size = 1024.00 MiB ( 8192 cells, 32 layers, 1/1 seqs), K (q8_0): 512.00 MiB, V (q8_0): 512.00 MiB\n" +
"load_tensors: offloaded 49/49 layers to GPU\n"
off, total, kv := tuneLogFacts(log)
if off != 49 || total != 49 || kv != "q8_0/q8_0" {
t.Errorf("faits du journal : %d/%d %q", off, total, kv)
}
if off, _, kv := tuneLogFacts("rien"); off != -1 || kv != "" {
t.Errorf("journal vide : %d %q", off, kv)
}
if !strings.Contains(tuneLoadFailure("ggml_backend_cuda_buffer_type_alloc_buffer: cudaMalloc failed: out of memory"), "OOM") {
t.Error("OOM non reconnu")
}
}
// Le score et le départage : un gain ne compte qu'au-delà de 3 % ET de
// l'écart entre passages, et sur deux passages.
func TestTuneScoreAndTieBreak(t *testing.T) {
w := tuneWeights{K: 2000, G: 600, FCold: 0.05}
res := &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50, Depth: &benchDepth{Target: 32768,
Cold: &benchTurn{PromptPerSecond: 800},
DecodePerSec: 40, CachedPerSec: 999,
Turns: []benchTurn{{New: 2100, Cached: 30000, Expected: 30048, PromptMs: 2000},
{New: 2100, Cached: 32300, Expected: 32348, PromptMs: 2000}}}}
r, err := tuneRunFrom(res, w)
if err != nil {
t.Fatal(err)
}
// Jetons neufs réels : (2100+30000-30048)+(2100+32300-32348) = 4104 en 4 s.
if r.CachedPP < 1025 || r.CachedPP > 1027 {
t.Errorf("prefill des tours : %.1f", r.CachedPP)
}
want := 2000/r.CachedPP + 600/40.0 + 0.05*32768/800
if d := r.TurnSec - want; d > 1e-9 || d < -1e-9 {
t.Errorf("durée d'un tour : %.3f au lieu de %.3f", r.TurnSec, want)
}
res.Depth.Partial = "contexte plein"
if _, err := tuneRunFrom(res, w); err == nil {
t.Error("mesure partielle acceptée")
}
base := []float64{20, 20.4}
if _, _, ok := tuneWins(base, []float64{19.7, 19.8}); ok {
t.Error("gain de ~2 % accepté (sous le seuil)")
}
if _, _, ok := tuneWins(base, []float64{18}); ok {
t.Error("un seul passage suffit à gagner")
}
if g, _, ok := tuneWins(base, []float64{18, 18.1}); !ok || g < 0.09 {
t.Errorf("gain net refusé (%.3f)", g)
}
// Écart entre passages plus grand que le gain : refusé.
if _, need, ok := tuneWins([]float64{18, 22}, []float64{18.5, 18.6}); ok || need < 0.19 {
t.Errorf("bruit non pris en compte (seuil %.2f)", need)
}
if !tuneWorthRepeat(base, 19) || tuneWorthRepeat(base, 19.8) {
t.Error("confirmation mal décidée")
}
}
// La réécriture du preset ne change que les clés réglées ; commentaires, NAME
// et autres clés restent, EXTRA_ARGS garde ses jetons interdits.
func TestTunePatchPreset(t *testing.T) {
content := "# NAME=Gros MoE\n# commentaire\nMODEL=big.gguf\nCTX=65536\nUBATCH=512\n" +
`EXTRA_ARGS="--cache-type-k q8_0 --flash-attn on -t 8"` + "\n"
base := parseEnv(content)
win := knobThreads.set(base, "")
win = setKey(win, "UBATCH", "2048")
win = setKey(win, "BATCH", "4096")
out, err := tunePatchPreset(content, base, win)
if err != nil {
t.Fatal(err)
}
for _, s := range []string{"# NAME=Gros MoE", "# commentaire", "MODEL=big.gguf", "CTX=65536", "UBATCH=2048", "BATCH=4096",
"--cache-type-k q8_0 --flash-attn on"} {
if !strings.Contains(out, s) {
t.Errorf("%q absent de :\n%s", s, out)
}
}
if strings.Contains(out, "-t 8") {
t.Errorf("-t non retiré :\n%s", out)
}
bad := setKey(base, "CTX", "32768")
if _, err := tunePatchPreset(content, base, bad); err == nil {
t.Error("clé intouchable réécrite")
}
}
// Le verrou : exclusif entre processus, refusé tant que son propriétaire vit,
// écarté quand il est mort ; et serviceAction refuse tout démarrage pendant ce
// temps, sur toutes les plateformes, avant de toucher au système.
func TestTuneLock(t *testing.T) {
testHome(t)
alive := true
prev := tuneProcAlive
tuneProcAlive = func(p tuneProc) bool { return alive && p.PID == os.Getpid() }
t.Cleanup(func() { tuneProcAlive = prev })
if err := tuneGuard(); err != nil {
t.Fatalf("garde levée sans verrou : %v", err)
}
l, err := tuneLockAcquire("cli", true)
if err != nil {
t.Fatal(err)
}
if !errors.Is(tuneGuard(), errTuneBusy) {
t.Error("garde muette pendant l'optimisation")
}
if _, err := tuneLockAcquire("web", false); err == nil {
t.Error("second verrou accordé")
}
for _, a := range []string{"start", "restart"} {
if err := serviceAction(a); !errors.Is(err, errTuneBusy) {
t.Errorf("serviceAction(%s) pendant l'optimisation : %v", a, err)
}
}
if err := SwitchToPreset(filepath.Join(t.TempDir(), "x.env")); !errors.Is(err, errTuneBusy) {
t.Errorf("bascule de preset pendant l'optimisation : %v", err)
}
l.setTrial(&tuneProc{PID: 999999, Start: "x"})
if o, ok := readTuneOwner(tuneLockPath()); !ok || o.Trial == nil || o.Trial.PID != 999999 || !o.MainWasActive {
t.Errorf("essai non noté dans le verrou : %+v", o)
}
l.release()
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
t.Error("verrou non rendu")
}
// Propriétaire mort : le verrou est périmé, il ne bloque rien et cède la
// place. L'essai noté n'est pas tué (identité non concordante).
l, err = tuneLockAcquire("cli", true)
if err != nil {
t.Fatal(err)
}
l.setTrial(&tuneProc{PID: 999999, Start: "x"})
alive = false
if err := tuneGuard(); err != nil {
t.Errorf("verrou périmé encore bloquant : %v", err)
}
if !tuneReapStale() {
t.Error("MainWasActive perdu au nettoyage")
}
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
t.Error("verrou périmé non retiré")
}
alive = true
l2, err := tuneLockAcquire("web", false)
if err != nil {
t.Fatalf("verrou refusé après nettoyage : %v", err)
}
l2.release()
// Un verrou d'un autre propriétaire n'est jamais retiré par release.
l3, _ := tuneLockAcquire("cli", false)
other := &tuneLock{path: tuneLockPath(), owner: tuneOwner{Owner: tuneProc{PID: os.Getpid(), Start: "autre"}}}
other.release()
if _, err := os.Stat(tuneLockPath()); err != nil {
t.Error("verrou d'autrui retiré")
}
l3.release()
}
// Le moteur d'essai ne lit ni n'écrit config.env : « loki serve » à blanc, avec
// la configuration de l'essai, compose sa ligne de commande (adresse privée,
// surcharges) et laisse la configuration active intacte.
func TestTuneTrialNeverWritesConfig(t *testing.T) {
home := testHome(t)
for _, k := range []string{"PATH", "LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "CUDA_VISIBLE_DEVICES", "CUDA_DEVICE_ORDER",
"CUDA_SCALE_LAUNCH_QUEUES", "GGML_OP_OFFLOAD_MIN_BATCH", "GGML_CUDA_GRAPH_OPT", tuneTrialEnv, tuneDryRunEnv} {
t.Setenv(k, os.Getenv(k))
}
bin := filepath.Join(home, "llama-server-factice")
model := filepath.Join(home, "m.gguf")
for _, p := range []string{bin, model} {
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
active := map[string]string{"BIN": bin, "MODEL": model, "CTX": "8192", "UBATCH": "512", "PORT": "8080",
"EXTRA_ARGS": "--cache-type-k q8_0 --port 9999"}
if err := WriteConfig(active); err != nil {
t.Fatal(err)
}
tr := &tuneTrialer{dir: filepath.Join(home, "tune", "run")}
trial := knobUBatch.set(active, "2048")
dir, port, err := tr.prepare(trial)
if err != nil {
t.Fatal(err)
}
t.Setenv(tuneTrialEnv, dir)
t.Setenv(tuneDryRunEnv, "1")
if err := cmdServe(nil); err != nil {
t.Fatalf("loki serve à blanc : %v", err)
}
if got := ReadConfig(); !reflect.DeepEqual(got, active) {
t.Errorf("configuration active modifiée :\n%v\nau lieu de\n%v", got, active)
}
l, err := readTuneLaunch(dir)
if err != nil {
t.Fatal(err)
}
if argValue(l.Args, "-ub") != "2048" || argValue(l.Args, "--host") != "127.0.0.1" ||
argValue(l.Args, "--port") != strconv.Itoa(port) || argValue(l.Args, "--cache-type-k") != "q8_0" {
t.Errorf("ligne de l'essai inattendue : %q", l.Args)
}
for i, a := range l.Args {
if a == "--port" && l.Args[i+1] == "9999" {
t.Errorf("--port d'EXTRA_ARGS non forcé : %q", l.Args)
}
}
if l.Probe == nil {
t.Error("sonde absente de la ligne de l'essai")
}
if _, err := os.Stat(filepath.Join(home, "slots")); err == nil {
t.Error("l'essai a touché au dossier des slots du vrai moteur")
}
}
// Après application, la configuration doit être exactement la référence plus
// les clés réglées.
func TestTuneAppliedMismatch(t *testing.T) {
base := map[string]string{"MODEL": "m.gguf", "UBATCH": "512", "EXTRA_ARGS": "-t 8"}
res := &tuneResult{BaseFP: configFingerprint(base), Set: map[string]string{"UBATCH": "2048", "EXTRA_ARGS": ""},
Old: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-t 8"}}
if why := tuneAppliedMismatch(map[string]string{"MODEL": "m.gguf", "UBATCH": "2048"}, res); why != "" {
t.Errorf("application conforme refusée : %s", why)
}
if why := tuneAppliedMismatch(map[string]string{"MODEL": "m.gguf", "UBATCH": "1024"}, res); why == "" {
t.Error("valeur réglée différente non vue")
}
if why := tuneAppliedMismatch(map[string]string{"MODEL": "autre.gguf", "UBATCH": "2048"}, res); why == "" {
t.Error("changement hors réglages non vu")
}
}
// Application : la copie ne touche pas au preset d'origine ; le preset lui-même
// est réécrit, le moteur redémarré, et une sonde qui échoue (OOM au premier
// long prompt, faux moteur) ramène l'ancienne version — preset ET configuration.
func TestTuneApplyAndRevert(t *testing.T) {
testHome(t)
content := "# NAME=Dense\nMODEL=m.gguf\nUBATCH=512\n"
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(presetsDir(), "Dense.env"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if err := WriteConfig(parseEnv(content)); err != nil {
t.Fatal(err)
}
res := &tuneResult{PresetID: "Dense", PresetName: "Dense", PresetFP: presetFingerprint([]byte(content)),
BaseFP: configFingerprint(parseEnv(content)), Set: map[string]string{"UBATCH": "2048"},
Old: map[string]string{"UBATCH": "512"}, Best: "UBATCH=2048"}
var calls []string
prevSvc, prevProbe := tuneSvc, tuneProbeFn
t.Cleanup(func() { tuneSvc, tuneProbeFn = prevSvc, prevProbe })
tuneSvc = func(a string) error { calls = append(calls, a); return nil }
if _, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {}); err != nil {
t.Fatalf("copie : %v", err)
}
if got, _ := ReadPreset("Dense"); got != content || len(calls) != 0 {
t.Fatalf("la copie a touché au preset ou au moteur : %q %v", got, calls)
}
copyBody, err := ReadPreset("Dense (optimisé)")
if err != nil || !strings.Contains(copyBody, "UBATCH=2048") {
t.Fatalf("copie absente ou fausse : %q %v", copyBody, err)
}
tuneProbeFn = func(context.Context, *tuneResult, func(string)) error { return errors.New("sonde : out of memory") }
_, err = tuneApply(context.Background(), res, tuneApplyPreset, func(string) {})
if err == nil || !strings.Contains(err.Error(), "rétablie") {
t.Fatalf("échec de la sonde sans retour arrière : %v", err)
}
if got, _ := ReadPreset("Dense"); got != content {
t.Errorf("preset non rétabli : %q", got)
}
if ReadConfig()["UBATCH"] != "512" {
t.Errorf("configuration non rétablie : %v", ReadConfig())
}
if !reflect.DeepEqual(calls, []string{"restart", "restart"}) {
t.Errorf("redémarrages : %v", calls)
}
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
t.Error("verrou non rendu après l'application")
}
calls = nil
tuneProbeFn = func(context.Context, *tuneResult, func(string)) error { return nil }
if _, err := tuneApply(context.Background(), res, tuneApplyPreset, func(string) {}); err != nil {
t.Fatalf("application : %v", err)
}
if got, _ := ReadPreset("Dense"); !strings.Contains(got, "UBATCH=2048") || !strings.Contains(got, "# NAME=Dense") {
t.Errorf("preset non réglé : %q", got)
}
if ReadConfig()["UBATCH"] != "2048" || len(calls) != 1 {
t.Errorf("configuration %v, redémarrages %v", ReadConfig(), calls)
}
// Le preset a changé depuis la mesure : plus rien ne s'applique.
if _, err := tuneApply(context.Background(), res, tuneApplyPreset, func(string) {}); err == nil {
t.Error("résultat appliqué sur un preset modifié depuis la mesure")
}
}
// TestTuneHelperSleep n'est pas un test : relancé par TestTuneReapOrphan comme
// faux essai orphelin, il dort jusqu'à ce qu'on le tue.
func TestTuneHelperSleep(t *testing.T) {
if os.Getenv("LOKI_TUNE_TEST_SLEEP") != "1" {
t.Skip("processus auxiliaire")
}
time.Sleep(60 * time.Second)
}
// Un essai orphelin (propriétaire du verrou mort) est arrêté avant tout
// démarrage du moteur — seulement si son identité concorde.
func TestTuneReapOrphan(t *testing.T) {
testHome(t)
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
cmd := exec.Command(self, "-test.run=^TestTuneHelperSleep$")
cmd.Env = append(os.Environ(), "LOKI_TUNE_TEST_SLEEP=1")
tuneTrialAttr(cmd)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { _ = cmd.Wait(); close(done) }()
t.Cleanup(func() { _ = cmd.Process.Kill() })
start, exe, ok := procIdentity(cmd.Process.Pid)
if !ok {
t.Fatal("identité du processus auxiliaire illisible")
}
// Propriétaire mort : un PID qui ne tourne plus (le test lui-même, avec un
// instant de démarrage qui n'est pas le sien).
dead := tuneProc{PID: os.Getpid(), Start: "pas-moi"}
write := func(trial tuneProc) {
b, _ := json.Marshal(tuneOwner{Owner: dead, Via: "cli", Trial: &trial})
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
t.Fatal(err)
}
}
// Identité qui ne concorde pas (PID recyclé) : on ne tue rien.
write(tuneProc{PID: cmd.Process.Pid, Start: start + "x", Exes: []string{exe}})
tuneReapStale()
select {
case <-done:
t.Fatal("processus tué sur la foi du seul PID")
case <-time.After(300 * time.Millisecond):
}
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
t.Fatal("verrou périmé non retiré")
}
// Identité complète : arrêté.
write(tuneProc{PID: cmd.Process.Pid, Start: start, Exes: []string{exe}})
if err := tuneGuard(); err != nil {
t.Fatalf("verrou périmé bloquant : %v", err)
}
tuneReapStale()
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("essai orphelin toujours en vie")
}
}
func TestTuneStageWanted(t *testing.T) {
for _, c := range []struct {
name string
only []string
want bool
}{
{"lots", nil, true},
{"délestage", []string{"delestage"}, true},
{"files CUDA", []string{"files"}, true},
{"threads", []string{"lots"}, false},
{"placement", []string{"aucune"}, true}, // sa propre case décide
{"opt-in", []string{"lots"}, true},
{"marges", []string{"aucune"}, false},
} {
if got := tuneStageWanted(c.name, c.only); got != c.want {
t.Errorf("%s / %v : %v", c.name, c.only, got)
}
}
}
// Corrections de relecture du lot 2 : un verrou qui porte NOTRE PID sans que
// nous en tenions un vient d'un processus mort (PID réattribué après un
// redémarrage, même instant de démarrage relatif au boot) : il ne bloque rien.
// Un verrou illisible tout juste créé est en cours d'écriture : ni retiré ni
// bloquant ; vieux, il est écarté.
func TestTuneLockStaleSelfAndFresh(t *testing.T) {
testHome(t)
prev := tuneProcAlive
tuneProcAlive = func(tuneProc) bool { return true } // l'identité concorde
t.Cleanup(func() { tuneProcAlive = prev })
if err := os.MkdirAll(filepath.Dir(tuneLockPath()), 0o755); err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(tuneOwner{Owner: selfProc(), Via: "web", Phase: "essais"})
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
t.Fatal(err)
}
if err := tuneGuard(); err != nil {
t.Fatalf("verrou d'un ancien processus au même PID encore bloquant : %v", err)
}
l, err := tuneLockAcquire("cli", false)
if err != nil {
t.Fatalf("verrou périmé non écarté : %v", err)
}
if !errors.Is(tuneGuard(), errTuneBusy) {
t.Error("notre propre verrou ne bloque plus")
}
l.release()
if _, held := tuneHeld.Load(tuneHeldKey(l.owner)); held {
t.Error("verrou rendu encore inscrit comme tenu")
}
// Illisible et neuf : laissé en place.
if err := os.WriteFile(tuneLockPath(), nil, 0o644); err != nil {
t.Fatal(err)
}
tuneReapStale()
if _, err := os.Stat(tuneLockPath()); err != nil {
t.Fatal("verrou en cours d'écriture retiré")
}
// Illisible et vieux : écarté.
old := time.Now().Add(-2 * tuneLockFreshness)
if err := os.Chtimes(tuneLockPath(), old, old); err != nil {
t.Fatal(err)
}
tuneReapStale()
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
t.Fatal("verrou illisible abandonné non retiré")
}
}
// Les binaires remplacés par une mise à jour (« (deleted) » sous Linux) restent
// le même processus.
func TestTuneSameExeDeleted(t *testing.T) {
if !sameExe("/usr/local/bin/loki", "/usr/local/bin/loki (deleted)") {
t.Error("binaire remplacé pris pour un autre")
}
if sameExe("/usr/local/bin/loki", "/usr/bin/llama-server") {
t.Error("binaires différents confondus")
}
}
// Une application interrompue avant sa vérification (Loki tué pendant la
// sonde) est défaite quand le verrou périmé est écarté : preset ET
// configuration reviennent à la version sauvegardée.
func TestTuneUndoInterruptedApply(t *testing.T) {
testHome(t)
prev := tuneProcAlive
tuneProcAlive = func(tuneProc) bool { return false } // propriétaire mort
t.Cleanup(func() { tuneProcAlive = prev })
old := "# NAME=Dense\nMODEL=m.gguf\nUBATCH=512\n"
patched := "# NAME=Dense\nMODEL=m.gguf\nUBATCH=2048\n"
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(presetsDir(), "Dense.env"), []byte(patched), 0o644); err != nil {
t.Fatal(err)
}
if err := WriteConfig(parseEnv(patched)); err != nil {
t.Fatal(err)
}
backup := filepath.Join(t.TempDir(), "Dense.env")
if err := os.WriteFile(backup, []byte(old), 0o600); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(tuneLockPath()), 0o755); err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(tuneOwner{Owner: tuneProc{PID: 999999, Start: "x"}, Via: "apply", Phase: "application",
Backup: backup, PresetID: "Dense", PresetName: "Dense", MainWasActive: true})
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
t.Fatal(err)
}
if !tuneReapStale() {
t.Error("MainWasActive perdu")
}
if got, _ := ReadPreset("Dense"); got != old {
t.Errorf("preset non rétabli : %q", got)
}
if ReadConfig()["UBATCH"] != "512" {
t.Errorf("configuration non rétablie : %v", ReadConfig())
}
// Hors phase d'application, rien n'est touché.
if err := WriteConfig(parseEnv(patched)); err != nil {
t.Fatal(err)
}
b, _ = json.Marshal(tuneOwner{Owner: tuneProc{PID: 999999, Start: "x"}, Via: "cli", Phase: "essais",
Backup: backup, PresetID: "Dense", PresetName: "Dense"})
if err := os.WriteFile(tuneLockPath(), b, 0o644); err != nil {
t.Fatal(err)
}
tuneReapStale()
if ReadConfig()["UBATCH"] != "2048" {
t.Error("configuration modifiée hors phase d'application")
}
}
// Reprise dans le processus web : un « loki tune » tué sans ses defers ne
// laisse plus le moteur arrêté jusqu'au prochain démarrage de l'interface. Le
// tic suivant écarte le verrou périmé et relance le moteur — une fois, et
// seulement s'il tournait avant. Sans verrou, ni lecture ni relance.
func TestTuneRecoverWatch(t *testing.T) {
testHome(t)
alive := true
prevAlive, prevStart := tuneProcAlive, tuneRecoverStart
tuneProcAlive = func(p tuneProc) bool { return alive && p.PID == os.Getpid() }
starts := 0
tuneRecoverStart = func() { starts++ }
t.Cleanup(func() { tuneProcAlive, tuneRecoverStart = prevAlive, prevStart })
tick := make(chan time.Time)
done := make(chan struct{})
go func() { tuneRecoverWatch(tick); close(done) }()
// Un envoi est reçu quand le tic précédent a fini : deux de suite, et le
// premier est traité en entier.
step := func() { tick <- time.Now() }
step()
step()
if starts != 0 {
t.Fatal("relance sans verrou")
}
l, err := tuneLockAcquire("cli", true)
if err != nil {
t.Fatal(err)
}
step()
step()
if starts != 0 {
t.Fatal("relance pendant une optimisation vivante")
}
if _, err := os.Stat(tuneLockPath()); err != nil {
t.Fatal("verrou vivant retiré")
}
alive = false // kill -9 : le verrou reste, son propriétaire est mort
step()
step()
if starts != 1 {
t.Fatalf("relances : %d, attendu 1", starts)
}
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
t.Error("verrou périmé non retiré")
}
l.release()
// Le moteur ne tournait pas avant : verrou retiré, pas de relance.
alive = true
if _, err := tuneLockAcquire("cli", false); err != nil {
t.Fatal(err)
}
alive = false
step()
step()
close(tick)
<-done
if starts != 1 {
t.Errorf("relance d'un moteur qui ne tournait pas : %d", starts)
}
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
t.Error("verrou périmé non retiré")
}
}
// « loki tune » d'un autre LOKI_HOME que le processus web (sudo) : le moteur
// qui répond sur le port de ce dossier n'est pas le sien — refus en clair.
// Tout ce qui ne conclut pas laisse passer.
func TestTuneForeignEngine(t *testing.T) {
home := testHome(t)
ours := filepath.Join(home, "ours.gguf")
other := filepath.Join(home, "other.gguf")
for _, p := range []string{ours, other} {
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
t.Fatal(err)
}
}
engine := func(health, props int, model string) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.WriteHeader(health)
case "/props":
w.WriteHeader(props)
if props == http.StatusOK {
_ = json.NewEncoder(w).Encode(map[string]any{"model_path": model})
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
closed := httptest.NewServer(http.NotFoundHandler())
closed.Close()
ctx := context.Background()
for _, c := range []struct {
name string
base string
active bool
refused bool
}{
{"rien n'écoute", closed.URL, false, false},
{"autre service (404) sur le port, moteur arrêté", engine(404, 404, "").URL, false, false},
{"moteur qui répond, ce dossier le dit arrêté", engine(200, 200, ours).URL, false, true},
{"moteur en chargement (503), ce dossier le dit arrêté", engine(503, 503, "").URL, false, true},
{"notre moteur", engine(200, 200, ours).URL, true, false},
{"un autre MODEL", engine(200, 200, other).URL, true, true},
{"clé d'API refusée", engine(200, 401, "").URL, true, true},
{"/props absent (moteur ancien)", engine(200, 404, "").URL, true, false},
{"chemin illisible d'ici", engine(200, 200, filepath.Join(home, "absent.gguf")).URL, true, false},
} {
err := tuneForeignEngine(ctx, c.base, c.active, nil, ours)
if (err != nil) != c.refused {
t.Errorf("%s : %v", c.name, err)
}
if err != nil && !strings.Contains(err.Error(), "LOKI_HOME="+home) {
t.Errorf("%s : message sans LOKI_HOME : %v", c.name, err)
}
}
}
// La reprise du processus web tourne toutes les 30 s, pendant que ce même
// processus prend et rend ses propres verrous (bouton « Optimiser… »,
// application d'un résultat). Elle ne doit jamais tenir l'un d'eux pour
// périmé : entre la création du fichier et l'inscription, ou entre
// l'inscription retirée et le fichier retiré, elle aurait défait un preset
// tout juste vérifié (phase « application ») et relancé le moteur.
func TestTuneRecoverNeverReapsOwnLock(t *testing.T) {
testHome(t)
prevAlive, prevStart := tuneProcAlive, tuneRecoverStart
tuneProcAlive = func(p tuneProc) bool { return p.PID == os.Getpid() }
var starts atomic.Int32
tuneRecoverStart = func() { starts.Add(1) }
t.Cleanup(func() { tuneProcAlive, tuneRecoverStart = prevAlive, prevStart })
stop := make(chan struct{})
reaped := make(chan struct{})
go func() {
defer close(reaped)
for {
select {
case <-stop:
return
default:
tuneRecoverStale()
}
}
}()
for i := 0; i < 60; i++ {
l, err := tuneLockAcquire("apply", true)
if err != nil {
t.Fatalf("tour %d : %v", i, err)
}
l.setPhase("application")
l.release()
}
close(stop)
<-reaped
if n := starts.Load(); n != 0 {
t.Errorf("verrou vivant de ce processus écarté %d fois", n)
}
}