Files
Loki/internal/loki/backend_tune_test.go
T
MichaelandClaude Opus 5.5 a94c752529 Optimiseur : corrections de relecture du lot 2 — verrou périmé reconnu, application interrompue défaite
Le verrou de l'optimiseur ne doit jamais bloquer quand aucune optimisation
ne tourne, et ne jamais passer pour périmé quand elle tourne. Plusieurs
identités de processus ne tenaient pas ces deux promesses hors Windows.

- Linux : l'instant de démarrage est relatif au boot. Après une coupure, un
  service relancé au même moment du démarrage retrouvait PID et top : le
  verrou de l'ancien passait pour vivant et tout était refusé jusqu'à son
  retrait à la main. L'identifiant du boot le préfixe ; un verrou qui porte
  notre PID sans être l'un des nôtres est périmé.
- Linux : un binaire remplacé par une mise à jour (« (deleted) ») faisait
  passer le propriétaire vivant pour mort. La mise à jour est de toute façon
  refusée pendant une optimisation (interface et loki update).
- macOS : ps rend lstart dans la langue et le fuseau de l'appelant ; le web
  (launchd) et un terminal en français ne lisaient pas la même identité. LC_ALL=C
  et TZ=UTC.
- Un verrou illisible tout juste créé (vide entre création et écriture) n'est
  plus retiré : seulement après 10 s.
- loki tune : terminal fermé ou kill (SIGTERM, SIGHUP) annulent proprement,
  comme Ctrl-C ; Ctrl-C retrouve son sens aux questions de fin.
- Application : la sauvegarde est notée dans le verrou ; un Loki tué avant la
  vérification remet l'ancienne version au redémarrage. Deux clics rapprochés
  ne lancent plus deux applications. Un échec de relance du vrai moteur se
  voit dans le résultat, plus seulement au journal ; un essai qui survit à son
  arrêt reste noté dans le verrou.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:25:39 +02:00

835 lines
31 KiB
Go

package loki
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"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")
}
}