mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset demandait de dupliquer, basculer et comparer des bench complets un par un. L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni à ce que calcule le modèle, et n'écrit rien sans un clic. - Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur 127.0.0.1 et un port libre, groupe de processus tué en sortie - Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage + binaire) vérifié par serviceAction start/restart et par le chat, les tâches, bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur - Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande - EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de chaque essai composée à blanc et comparée à la référence (« dénature »), contexte, slots et cache KV recontrôlés une fois chargé - Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages) - Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit clé par clé, vérifié par une sonde et rétabli en cas d'échec Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1325 lines
42 KiB
Go
1325 lines
42 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Optimiseur sans perte (« loki tune », bouton « Optimiser » de l'éditeur de
|
|
// preset) — la partie PURE : ce qu'on a le droit de changer, les essais à
|
|
// tenter, la comparaison des lignes de commande, le score, la réécriture du
|
|
// preset. Le déroulé (moteur d'essai, mesures, verrou) vit dans
|
|
// backend_tune_run.go.
|
|
//
|
|
// Principe : chercher, pour CETTE machine et CE build du moteur, le placement,
|
|
// les lots, les threads (et, sur demande, la spéculation) qui rendent un tour de
|
|
// conversation le plus court — sans rien changer à ce que calcule le modèle.
|
|
// Seuls des réglages d'ordonnancement bougent : la sortie reste équivalente en
|
|
// distribution (les sommes flottantes ne sont pas identiques au bit près d'un
|
|
// lot, d'un placement ou d'un nombre de threads à l'autre — c'est déjà le cas
|
|
// entre deux tailles de prompt) ; la spéculation vérifie chaque jeton par le
|
|
// modèle, avec son propre échantillonnage.
|
|
//
|
|
// Jamais touchés : modèle et quantification, contexte, types du cache KV
|
|
// (même déjà quantifié : c'est alors signalé, jamais modifié), projecteur
|
|
// vision, raisonnement, échantillonnage, nombre de slots, et tout drapeau
|
|
// d'EXTRA_ARGS hors de la liste blanche. Un essai dont la ligne de commande
|
|
// diffère de la référence ailleurs que sur les drapeaux réglés est écarté
|
|
// (« dénature ») avant même d'être chargé.
|
|
|
|
const (
|
|
tuneTrialEnv = "LOKI_TUNE_TRIAL" // dossier de l'essai : « loki serve » y lit sa configuration
|
|
tuneDryRunEnv = "LOKI_TUNE_DRYRUN" // « loki serve » compose la ligne de commande, l'écrit, et s'arrête
|
|
)
|
|
|
|
// tuneKeys : les clés du preset que l'optimiseur peut écrire. Toute autre clé
|
|
// reste identique à l'octet près (tuneCfgCheck).
|
|
var tuneKeys = map[string]bool{
|
|
"BATCH": true, "UBATCH": true, "THREADS": true, "THREADS_BATCH": true,
|
|
"FIT_TARGET": true, "OP_OFFLOAD_MIN_BATCH": true, "CUDA_LAUNCH_QUEUES": true,
|
|
"EXTRA_ARGS": true, // jeton par jeton, liste blanche seulement (tuneFlags)
|
|
}
|
|
|
|
// tuneOptInKeys : seulement si l'utilisateur coche « inclure les options
|
|
// opt-in » — décodage spéculatif et graphes CUDA, eux-mêmes opt-in.
|
|
var tuneOptInKeys = map[string]bool{"SPEC": true, "SPEC_N_MAX": true, "CUDA_GRAPH_OPT": true}
|
|
|
|
// tuneNeverKeys : ce que l'optimiseur ne touche jamais. Documentaire, et vérifié
|
|
// par les tests : aucune n'est dans tuneKeys ni tuneOptInKeys.
|
|
var tuneNeverKeys = []string{"MODEL", "CTX", "KV_TYPE", "KV_TYPE_K", "KV_TYPE_V", "MMPROJ",
|
|
"REASONING", "REASONING_BUDGET", "REASONING_EFFORT", "REASONING_PRESERVE", "REASONING_ECHO",
|
|
"TEMP", "TOP_P", "TOP_K", "MIN_P", "PRESENCE_PENALTY", "REPEAT_PENALTY",
|
|
"PARALLEL", "SIDE_SLOT", "MODEL_DRAFT", "SPEC_SAMPLING", "NGL", "SPLIT_MODE",
|
|
"CUDA_VISIBLE_DEVICES", "BIN", "HOST", "PORT"}
|
|
|
|
// tuneFlag : un drapeau d'EXTRA_ARGS que l'optimiseur peut retirer ou régler.
|
|
type tuneFlag struct {
|
|
names []string
|
|
arity int // 0 = interrupteur, 1 = une valeur
|
|
optIn bool // seulement avec « inclure les options opt-in »
|
|
}
|
|
|
|
// tuneFlags : la liste blanche. Placement des experts (-ot : seulement les
|
|
// éléments qui visent les experts), découpe entre cartes, --fit, lots,
|
|
// threads ; spéculation et échantillonnage sur GPU en opt-in.
|
|
var tuneFlags = []tuneFlag{
|
|
{names: []string{"-ot", "--override-tensor"}, arity: 1},
|
|
{names: []string{"-ncmoe", "--n-cpu-moe"}, arity: 1},
|
|
{names: []string{"-cmoe", "--cpu-moe"}},
|
|
{names: []string{"-sm", "--split-mode"}, arity: 1},
|
|
{names: []string{"-ts", "--tensor-split"}, arity: 1},
|
|
{names: []string{"-mg", "--main-gpu"}, arity: 1},
|
|
{names: []string{"-fit", "--fit"}, arity: 1},
|
|
{names: []string{"-fitt", "--fit-target"}, arity: 1},
|
|
{names: []string{"-b", "--batch-size"}, arity: 1},
|
|
{names: []string{"-ub", "--ubatch-size"}, arity: 1},
|
|
{names: []string{"-t", "--threads"}, arity: 1},
|
|
{names: []string{"-tb", "--threads-batch"}, arity: 1},
|
|
{names: []string{"--spec-draft-n-max", "--draft-max", "--draft-n"}, arity: 1, optIn: true},
|
|
{names: []string{"-bs", "--backend-sampling"}, optIn: true},
|
|
}
|
|
|
|
// tuneForbiddenFlags : jamais ajoutés, retirés ni modifiés — copiés tels quels,
|
|
// dans l'ordre. Documentaire et testé : aucun n'est dans tuneFlags.
|
|
var tuneForbiddenFlags = []string{"-ctk", "--cache-type-k", "-ctv", "--cache-type-v", "-fa", "--flash-attn",
|
|
"-c", "--ctx-size", "--temp", "--top-k", "--top-p", "--min-p", "--samplers", "--repeat-penalty",
|
|
"--repeat-last-n", "--dry-multiplier", "--reasoning", "--reasoning-budget", "--reasoning-format",
|
|
"--chat-template", "--chat-template-file", "--jinja", "--cache-reuse", "--context-shift", "--swa-full",
|
|
"-kvu", "--kv-unified", "--keep", "-np", "--parallel", "-m", "--model", "--mmproj", "-ngl", "--n-gpu-layers"}
|
|
|
|
// tuneFlagFor : l'entrée de la liste blanche pour ce nom, ou nil.
|
|
func tuneFlagFor(name string, optIn bool) *tuneFlag {
|
|
for i := range tuneFlags {
|
|
f := &tuneFlags[i]
|
|
if f.optIn && !optIn {
|
|
continue
|
|
}
|
|
for _, n := range f.names {
|
|
if n == name {
|
|
return f
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- EXTRA_ARGS jeton par jeton ----------------------------------------------
|
|
|
|
// argSpan : un argument d'EXTRA_ARGS et sa place exacte dans la chaîne
|
|
// (guillemets compris). Les réécritures coupent et recollent la chaîne
|
|
// d'origine : un jeton hors liste blanche ressort à l'octet près, guillemets et
|
|
// espacement compris.
|
|
type argSpan struct {
|
|
start, end int
|
|
val string
|
|
}
|
|
|
|
// argSpans découpe comme splitArgs (mêmes règles de guillemets, mêmes jetons),
|
|
// en gardant les positions. Les tests vérifient l'égalité des deux découpages.
|
|
func argSpans(s string) []argSpan {
|
|
var out []argSpan
|
|
var cur strings.Builder
|
|
var quote rune
|
|
start := -1
|
|
flush := func(end int) {
|
|
if cur.Len() > 0 {
|
|
out = append(out, argSpan{start, end, cur.String()})
|
|
cur.Reset()
|
|
}
|
|
start = -1
|
|
}
|
|
for i, r := range s {
|
|
switch {
|
|
case quote != 0:
|
|
if r == quote {
|
|
quote = 0
|
|
if cur.Len() == 0 {
|
|
// « "" » : un argument vide explicite, comme splitArgs.
|
|
out = append(out, argSpan{start, i + 1, ""})
|
|
start = -1
|
|
}
|
|
} else {
|
|
cur.WriteRune(r)
|
|
}
|
|
case r == '"' || r == '\'':
|
|
if start < 0 {
|
|
start = i
|
|
}
|
|
quote = r
|
|
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
|
|
flush(i)
|
|
default:
|
|
if start < 0 {
|
|
start = i
|
|
}
|
|
cur.WriteRune(r)
|
|
}
|
|
}
|
|
flush(len(s))
|
|
return out
|
|
}
|
|
|
|
// extraFlagSpan : une occurrence d'un drapeau, avec sa valeur éventuelle.
|
|
type extraFlagSpan struct {
|
|
name string
|
|
flag argSpan
|
|
value *argSpan // nil : interrupteur, « nom=valeur », ou valeur manquante
|
|
eqVal string // valeur de la forme « nom=valeur »
|
|
hasEq bool
|
|
tokens []argSpan // les jetons qui la composent
|
|
}
|
|
|
|
// extraFlags repère, dans EXTRA_ARGS, les occurrences des drapeaux nommés.
|
|
// arity : 1 si le drapeau prend une valeur.
|
|
func extraFlags(spans []argSpan, arity int, names ...string) []extraFlagSpan {
|
|
var out []extraFlagSpan
|
|
for i := 0; i < len(spans); i++ {
|
|
name, val, hasEq := strings.Cut(spans[i].val, "=")
|
|
hit := false
|
|
for _, n := range names {
|
|
if name == n {
|
|
hit = true
|
|
}
|
|
}
|
|
if !hit {
|
|
continue
|
|
}
|
|
f := extraFlagSpan{name: name, flag: spans[i], eqVal: val, hasEq: hasEq, tokens: []argSpan{spans[i]}}
|
|
if arity == 1 && !hasEq && i+1 < len(spans) {
|
|
v := spans[i+1]
|
|
f.value = &v
|
|
f.tokens = append(f.tokens, v)
|
|
i++
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// spliceEdit : un morceau de la chaîne d'origine remplacé (repl vide = retiré).
|
|
type spliceEdit struct {
|
|
start, end int
|
|
repl string
|
|
}
|
|
|
|
// applyEdits applique des remplacements disjoints. Un retrait emporte les
|
|
// blancs qui le précèdent (ou, en tête de chaîne, ceux qui le suivent) : pas de
|
|
// double espace à la place d'un drapeau retiré. Le reste n'est pas touché.
|
|
func applyEdits(s string, edits []spliceEdit) string {
|
|
if len(edits) == 0 {
|
|
return s
|
|
}
|
|
sort.Slice(edits, func(i, j int) bool { return edits[i].start < edits[j].start })
|
|
var b strings.Builder
|
|
last := 0
|
|
for _, e := range edits {
|
|
start, end := e.start, e.end
|
|
if e.repl == "" {
|
|
ws := start
|
|
for ws > last && (s[ws-1] == ' ' || s[ws-1] == '\t') {
|
|
ws--
|
|
}
|
|
if ws == start || ws == 0 {
|
|
for end < len(s) && (s[end] == ' ' || s[end] == '\t') {
|
|
end++
|
|
}
|
|
if ws == 0 {
|
|
start = 0
|
|
}
|
|
} else {
|
|
start = ws
|
|
}
|
|
}
|
|
if start < last {
|
|
start = last
|
|
}
|
|
b.WriteString(s[last:start])
|
|
b.WriteString(e.repl)
|
|
last = end
|
|
}
|
|
b.WriteString(s[last:])
|
|
return strings.TrimSpace(b.String())
|
|
}
|
|
|
|
// quoteArg : un argument tel que splitArgs le relira d'un tenant.
|
|
func quoteArg(a string) string { return joinArgs([]string{a}) }
|
|
|
|
// tuneExtraSet règle un drapeau à valeur dans EXTRA_ARGS : la DERNIÈRE
|
|
// occurrence (celle que retient le moteur) voit sa valeur remplacée sur place ;
|
|
// absent, le drapeau est ajouté en fin de chaîne sous son premier nom. val vide
|
|
// retire toutes les occurrences.
|
|
func tuneExtraSet(s string, names []string, val string) string {
|
|
occ := extraFlags(argSpans(s), 1, names...)
|
|
if val == "" {
|
|
var edits []spliceEdit
|
|
for _, o := range occ {
|
|
for _, t := range o.tokens {
|
|
edits = append(edits, spliceEdit{t.start, t.end, ""})
|
|
}
|
|
}
|
|
return applyEdits(s, edits)
|
|
}
|
|
if len(occ) == 0 {
|
|
add := names[0] + " " + quoteArg(val)
|
|
if strings.TrimSpace(s) == "" {
|
|
return add
|
|
}
|
|
return strings.TrimRight(s, " \t") + " " + add
|
|
}
|
|
o := occ[len(occ)-1]
|
|
switch {
|
|
case o.hasEq:
|
|
return applyEdits(s, []spliceEdit{{o.flag.start, o.flag.end, o.name + "=" + quoteArg(val)}})
|
|
case o.value != nil:
|
|
return applyEdits(s, []spliceEdit{{o.value.start, o.value.end, quoteArg(val)}})
|
|
default:
|
|
return s[:o.flag.end] + " " + quoteArg(val) + s[o.flag.end:]
|
|
}
|
|
}
|
|
|
|
// tuneExtraDrop retire toutes les occurrences des drapeaux (valeur comprise).
|
|
func tuneExtraDrop(s string, arity int, names ...string) string {
|
|
var edits []spliceEdit
|
|
for _, o := range extraFlags(argSpans(s), arity, names...) {
|
|
for _, t := range o.tokens {
|
|
edits = append(edits, spliceEdit{t.start, t.end, ""})
|
|
}
|
|
}
|
|
return applyEdits(s, edits)
|
|
}
|
|
|
|
// tuneExtraAddSwitch ajoute un interrupteur absent (--backend-sampling).
|
|
func tuneExtraAddSwitch(s, name string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return name
|
|
}
|
|
return strings.TrimRight(s, " \t") + " " + name
|
|
}
|
|
|
|
// tuneExtraDropExperts retire le placement MANUEL des experts : les éléments
|
|
// d'un -ot qui visent les experts (les autres restent, sur place), --n-cpu-moe,
|
|
// --cpu-moe. Rend aussi ce qui a été retiré, pour le dire.
|
|
func tuneExtraDropExperts(s string) (string, []string) {
|
|
spans := argSpans(s)
|
|
var edits []spliceEdit
|
|
var removed []string
|
|
for _, o := range extraFlags(spans, 1, "-ot", "--override-tensor") {
|
|
v := o.eqVal
|
|
if !o.hasEq {
|
|
if o.value == nil {
|
|
continue
|
|
}
|
|
v = o.value.val
|
|
}
|
|
var rest []string
|
|
hit := false
|
|
for _, el := range strings.Split(v, ",") {
|
|
el = strings.TrimSpace(el)
|
|
pat, _, _ := strings.Cut(el, "=")
|
|
switch {
|
|
case el == "":
|
|
case otTargetsExperts(pat):
|
|
removed = append(removed, o.name+" "+el)
|
|
hit = true
|
|
default:
|
|
rest = append(rest, el)
|
|
}
|
|
}
|
|
if !hit {
|
|
continue // rien visant les experts : intouché
|
|
}
|
|
switch {
|
|
case len(rest) == 0:
|
|
for _, t := range o.tokens {
|
|
edits = append(edits, spliceEdit{t.start, t.end, ""})
|
|
}
|
|
case o.hasEq:
|
|
edits = append(edits, spliceEdit{o.flag.start, o.flag.end, o.name + "=" + quoteArg(strings.Join(rest, ","))})
|
|
default:
|
|
edits = append(edits, spliceEdit{o.value.start, o.value.end, quoteArg(strings.Join(rest, ","))})
|
|
}
|
|
}
|
|
for _, o := range extraFlags(spans, 1, "-ncmoe", "--n-cpu-moe") {
|
|
v := o.eqVal
|
|
if o.value != nil {
|
|
v = o.value.val
|
|
}
|
|
removed = append(removed, o.name+" "+v)
|
|
for _, t := range o.tokens {
|
|
edits = append(edits, spliceEdit{t.start, t.end, ""})
|
|
}
|
|
}
|
|
for _, o := range extraFlags(spans, 0, "-cmoe", "--cpu-moe") {
|
|
removed = append(removed, o.name)
|
|
edits = append(edits, spliceEdit{o.flag.start, o.flag.end, ""})
|
|
}
|
|
return applyEdits(s, edits), removed
|
|
}
|
|
|
|
// --- réglages : par la clé, ou par le drapeau qui l'écrase ---------------------
|
|
|
|
// tuneKnob : une valeur réglable, par sa clé du preset ou par le drapeau
|
|
// d'EXTRA_ARGS qui l'emporterait sur elle. Écrire la clé quand EXTRA_ARGS porte
|
|
// déjà le drapeau ne changerait rien au moteur — et l'essai mesurerait autre
|
|
// chose que ce que le preset appliqué reproduira. On règle donc le drapeau, sur
|
|
// place.
|
|
type tuneKnob struct {
|
|
key string
|
|
flags []string
|
|
}
|
|
|
|
var (
|
|
knobBatch = tuneKnob{"BATCH", []string{"-b", "--batch-size"}}
|
|
knobUBatch = tuneKnob{"UBATCH", []string{"-ub", "--ubatch-size"}}
|
|
knobThreads = tuneKnob{"THREADS", []string{"-t", "--threads"}}
|
|
knobFitTarget = tuneKnob{"FIT_TARGET", []string{"-fitt", "--fit-target"}}
|
|
knobSpecN = tuneKnob{"SPEC_N_MAX", []string{"--spec-draft-n-max"}}
|
|
)
|
|
|
|
// get : la valeur effective (drapeau d'EXTRA_ARGS, sinon la clé).
|
|
func (k tuneKnob) get(cfg map[string]string) string {
|
|
if v := flagValue(splitArgs(cfg["EXTRA_ARGS"]), k.flags...); v != "" {
|
|
return v
|
|
}
|
|
return strings.TrimSpace(cfg[k.key])
|
|
}
|
|
|
|
// set rend une COPIE de cfg où la valeur vaut val ("" = retirée).
|
|
func (k tuneKnob) set(cfg map[string]string, val string) map[string]string {
|
|
out := copyCfg(cfg)
|
|
if hasAnyFlag(splitArgs(cfg["EXTRA_ARGS"]), k.flags...) {
|
|
out["EXTRA_ARGS"] = tuneExtraSet(cfg["EXTRA_ARGS"], k.flags, val)
|
|
if out["EXTRA_ARGS"] == "" {
|
|
delete(out, "EXTRA_ARGS")
|
|
}
|
|
return out
|
|
}
|
|
if val == "" {
|
|
delete(out, k.key)
|
|
} else {
|
|
out[k.key] = val
|
|
}
|
|
return out
|
|
}
|
|
|
|
func copyCfg(cfg map[string]string) map[string]string {
|
|
out := make(map[string]string, len(cfg))
|
|
for k, v := range cfg {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// setKey : copie de cfg avec key = val ("" = retirée).
|
|
func setKey(cfg map[string]string, key, val string) map[string]string {
|
|
out := copyCfg(cfg)
|
|
if val == "" {
|
|
delete(out, key)
|
|
} else {
|
|
out[key] = val
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- les essais ------------------------------------------------------------------
|
|
|
|
// tuneEnv : ce que l'optimiseur sait de la machine et de ce que l'utilisateur
|
|
// a coché.
|
|
type tuneEnv struct {
|
|
Help string
|
|
GPUs int // cartes visibles par le moteur ; 0 = inconnu
|
|
VRAMMiB int64 // VRAM totale des cartes visibles ; 0 = inconnue
|
|
ModelBytes int64
|
|
MoE bool // le GGUF annonce des experts
|
|
PhysCores int // cœurs physiques ; 0 = inconnu (axe threads sauté)
|
|
SMI bool // VRAM libre mesurable par carte (nvidia-smi)
|
|
ArgEnv map[string]string // LLAMA_ARG_* de l'environnement
|
|
UserEnv map[string]string // GGML_* déjà posés (intouchés)
|
|
QueuesEnv bool // CUDA_SCALE_LAUNCH_QUEUES déjà posé
|
|
Placement bool // « inclure le placement » : EXTRA_ARGS réécrit pour --fit
|
|
OptIn bool // « inclure les options opt-in »
|
|
}
|
|
|
|
// tuneCand : un essai — la configuration COMPLÈTE à lancer (copie + surcharges).
|
|
type tuneCand struct {
|
|
Stage string `json:"stage"`
|
|
Label string `json:"label"`
|
|
Cfg map[string]string `json:"-"`
|
|
}
|
|
|
|
// tuneStage : une étape de la recherche, appliquée à la meilleure configuration
|
|
// du moment (descente coordonnée). why : pourquoi l'étape est sautée.
|
|
type tuneStage struct {
|
|
Name string
|
|
Gen func(cfg map[string]string, env tuneEnv) (cands []tuneCand, why string)
|
|
}
|
|
|
|
// tuneStages : l'ordre de la recherche. Le placement d'abord (il décide de ce
|
|
// qui reste en RAM, donc de l'intérêt des étapes suivantes), puis les lots,
|
|
// puis ce qui ne compte que pour des poids sur CPU, puis l'opt-in.
|
|
var tuneStages = []tuneStage{
|
|
{"placement", tunePlacementCands},
|
|
{"marges", tuneFitTargetCands},
|
|
{"lots", tuneBatchCands},
|
|
{"délestage", tuneOffloadCands},
|
|
{"threads", tuneThreadCands},
|
|
{"files CUDA", tuneQueueCands},
|
|
{"opt-in", tuneOptInCands},
|
|
}
|
|
|
|
// tuneStageWanted : l'étape fait-elle partie de celles demandées ? only vide =
|
|
// toutes. Placement et opt-in ne se filtrent pas ici : ils ont leur case, et
|
|
// leurs générateurs refusent sans elle. Noms comparés sans accent ni casse, par
|
|
// leur premier mot (« files CUDA » = « files », « délestage » = « delestage »).
|
|
func tuneStageWanted(name string, only []string) bool {
|
|
norm := func(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
s = strings.NewReplacer("é", "e", "è", "e", "ê", "e").Replace(s)
|
|
if i := strings.IndexAny(s, " -"); i > 0 && s != "opt-in" {
|
|
s = s[:i]
|
|
}
|
|
return s
|
|
}
|
|
n := norm(name)
|
|
if len(only) == 0 || n == "placement" || n == "opt-in" {
|
|
return true
|
|
}
|
|
for _, o := range only {
|
|
if norm(o) == n {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tuneSI(env tuneEnv) serveSysInfo {
|
|
a := env.ArgEnv
|
|
if a == nil {
|
|
a = map[string]string{}
|
|
}
|
|
return serveSysInfo{Help: env.Help, ArgEnv: a, UserEnv: env.UserEnv}
|
|
}
|
|
|
|
// tuneWeightsOnCPU : des poids resteront-ils en RAM ? Placés à la main, ou un
|
|
// modèle plus gros que la VRAM (--fit en laisse alors une part sur CPU).
|
|
func tuneWeightsOnCPU(cfg map[string]string, env tuneEnv) bool {
|
|
if cpuWeights(cfg, splitArgs(cfg["EXTRA_ARGS"]), env.ArgEnv, 0) != "" {
|
|
return true
|
|
}
|
|
return env.VRAMMiB > 0 && env.ModelBytes > env.VRAMMiB<<20
|
|
}
|
|
|
|
// tunePlacementCands : le placement manuel (experts au CPU, --tensor-split)
|
|
// remplacé par --fit. Seulement si l'utilisateur l'a coché : c'est une
|
|
// réécriture d'EXTRA_ARGS, montrée et confirmée à part à l'application.
|
|
func tunePlacementCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) {
|
|
if !env.Placement {
|
|
return nil, "non demandé (« inclure le placement »)"
|
|
}
|
|
extra := splitArgs(cfg["EXTRA_ARGS"])
|
|
if !helpSupportsFit(env.Help) {
|
|
return nil, "ce moteur ne connaît pas --fit"
|
|
}
|
|
if c, ok := ctxFixed(cfg, extra); !ok {
|
|
return nil, "contexte « " + c + " » non chiffré : --fit pourrait le réduire"
|
|
}
|
|
if splitModeKey(cfg) == "tensor" {
|
|
return nil, "SPLIT_MODE=tensor"
|
|
}
|
|
moe := cpuExperts(extra, env.ArgEnv) != ""
|
|
ts := hasAnyFlag(extra, "-ts", "--tensor-split") && env.GPUs >= 2
|
|
if !moe && !ts {
|
|
return nil, "rien n'est placé à la main"
|
|
}
|
|
e := cfg["EXTRA_ARGS"]
|
|
label := "placement auto (--fit)"
|
|
if moe {
|
|
e, _ = tuneExtraDropExperts(e)
|
|
label += " au lieu des experts placés à la main"
|
|
}
|
|
if ts {
|
|
e = tuneExtraDrop(e, 1, "-ts", "--tensor-split")
|
|
label += " au lieu de --tensor-split"
|
|
}
|
|
nc := setKey(cfg, "EXTRA_ARGS", e)
|
|
if why := fitBlocker(nc, splitArgs(e), tuneSI(env)); why != "" {
|
|
return nil, "--fit resterait inactif (" + why + ")"
|
|
}
|
|
return []tuneCand{{Stage: "placement", Label: label, Cfg: nc}}, ""
|
|
}
|
|
|
|
// tuneFitTargetCands : la marge que --fit laisse libre par carte. Sur deux
|
|
// cartes inégales, plus de marge sur l'une pousse des couches vers l'autre.
|
|
func tuneFitTargetCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) {
|
|
extra := splitArgs(cfg["EXTRA_ARGS"])
|
|
switch {
|
|
case env.GPUs < 2:
|
|
return nil, "une seule carte"
|
|
case !env.SMI:
|
|
return nil, "VRAM libre non mesurable (nvidia-smi)"
|
|
case !strings.Contains(env.Help, "--fit-target"):
|
|
return nil, "ce moteur ne connaît pas --fit-target"
|
|
}
|
|
if _, ok := ctxFixed(cfg, extra); !ok {
|
|
return nil, "contexte non chiffré"
|
|
}
|
|
if why := fitBlocker(cfg, extra, tuneSI(env)); why != "" {
|
|
return nil, "--fit inactif (" + why + ")"
|
|
}
|
|
vals := []string{"1536", "2048"}
|
|
if env.GPUs == 2 {
|
|
vals = []string{"1024,2048", "2048,1024", "1536"}
|
|
}
|
|
cur := knobFitTarget.get(cfg)
|
|
var out []tuneCand
|
|
for _, v := range vals {
|
|
if v == cur {
|
|
continue
|
|
}
|
|
out = append(out, tuneCand{Stage: "marges", Label: "FIT_TARGET=" + v, Cfg: knobFitTarget.set(cfg, v)})
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// tuneBatchCands : micro-lot et lot. Un micro-lot plus grand prend plus de VRAM
|
|
// de calcul : sans mesure de la VRAM libre, on ne monte pas. Sur deux cartes
|
|
// tout GPU, le lot reste plus grand que le micro-lot, sans quoi le pipeline
|
|
// entre cartes n'a rien à se passer.
|
|
func tuneBatchCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) {
|
|
extra := splitArgs(cfg["EXTRA_ARGS"])
|
|
curUB := serveUBatch(cfg, extra)
|
|
curB, _ := strconv.Atoi(firstNonEmpty(knobBatch.get(cfg), "2048"))
|
|
ubs := []int{512, 1024, 2048}
|
|
if env.MoE {
|
|
ubs = append(ubs, 4096)
|
|
}
|
|
multi := env.GPUs >= 2 && !tuneWeightsOnCPU(cfg, env)
|
|
var out []tuneCand
|
|
for _, ub := range ubs {
|
|
if !env.SMI && ub > curUB {
|
|
continue
|
|
}
|
|
seen := map[int]bool{}
|
|
for _, b := range []int{max(2048, ub), min(2*ub, 8192)} {
|
|
if seen[b] || b < ub || (multi && b <= ub) || (ub == curUB && b == curB) {
|
|
continue
|
|
}
|
|
seen[b] = true
|
|
c := knobUBatch.set(cfg, strconv.Itoa(ub))
|
|
c = knobBatch.set(c, strconv.Itoa(b))
|
|
out = append(out, tuneCand{Stage: "lots", Label: fmt.Sprintf("UBATCH=%d BATCH=%d", ub, b), Cfg: c})
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, "rien à essayer"
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// tuneOffloadCands : seuil d'envoi au GPU des poids restés en RAM. N'a de sens
|
|
// qu'avec des poids sur CPU.
|
|
func tuneOffloadCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) {
|
|
if !tuneWeightsOnCPU(cfg, env) {
|
|
return nil, "tous les poids sur GPU"
|
|
}
|
|
if env.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" {
|
|
return nil, "GGML_OP_OFFLOAD_MIN_BATCH déjà posé dans l'environnement"
|
|
}
|
|
cur := firstNonEmpty(strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"]), "32")
|
|
var out []tuneCand
|
|
for _, v := range []string{"32", "128", "512"} {
|
|
if v == cur {
|
|
continue
|
|
}
|
|
out = append(out, tuneCand{Stage: "délestage", Label: "OP_OFFLOAD_MIN_BATCH=" + v,
|
|
Cfg: setKey(cfg, "OP_OFFLOAD_MIN_BATCH", v)})
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// tuneThreadCands : threads CPU, seulement quand des poids tournent sur CPU.
|
|
// « Omis » = aucun -t : llama.cpp prend ses cœurs physiques — pas THREADS=0,
|
|
// qui voulait dire « tous les threads logiques » avant le lot 1.
|
|
func tuneThreadCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) {
|
|
if !tuneWeightsOnCPU(cfg, env) {
|
|
return nil, "tous les poids sur GPU"
|
|
}
|
|
if env.PhysCores <= 1 {
|
|
return nil, "nombre de cœurs physiques inconnu"
|
|
}
|
|
cur := knobThreads.get(cfg)
|
|
if cur == "0" {
|
|
cur = ""
|
|
}
|
|
var out []tuneCand
|
|
seen := map[string]bool{cur: true}
|
|
for _, n := range []int{0, env.PhysCores - 1, env.PhysCores / 2} {
|
|
v := ""
|
|
if n > 0 {
|
|
v = strconv.Itoa(n)
|
|
}
|
|
if seen[v] || n < 0 {
|
|
continue
|
|
}
|
|
seen[v] = true
|
|
label := "THREADS=" + v
|
|
if v == "" {
|
|
label = "threads omis (cœurs physiques)"
|
|
}
|
|
out = append(out, tuneCand{Stage: "threads", Label: label, Cfg: knobThreads.set(cfg, v)})
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// tuneQueueCands : files de lancement CUDA, sur deux cartes ou plus.
|
|
func tuneQueueCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) {
|
|
if env.GPUs < 2 {
|
|
return nil, "une seule carte"
|
|
}
|
|
if env.QueuesEnv {
|
|
return nil, "CUDA_SCALE_LAUNCH_QUEUES déjà posé dans l'environnement"
|
|
}
|
|
cur := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]))
|
|
var out []tuneCand
|
|
for _, v := range []string{"off", "4x"} {
|
|
if v == cur {
|
|
continue
|
|
}
|
|
out = append(out, tuneCand{Stage: "files CUDA", Label: "CUDA_LAUNCH_QUEUES=" + v,
|
|
Cfg: setKey(cfg, "CUDA_LAUNCH_QUEUES", v)})
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// tuneOptInCands : décodage spéculatif, graphes CUDA, échantillonnage sur GPU —
|
|
// seulement si l'utilisateur l'a coché. La spéculation reste exacte (chaque
|
|
// jeton vérifié par le modèle) ; elle est notée sur la prose ET sur le code.
|
|
func tuneOptInCands(cfg map[string]string, env tuneEnv) ([]tuneCand, string) {
|
|
if !env.OptIn {
|
|
return nil, "non demandé (« inclure les options opt-in »)"
|
|
}
|
|
extra := splitArgs(cfg["EXTRA_ARGS"])
|
|
var out []tuneCand
|
|
mode := specMode(cfg)
|
|
if mode == "off" && helpSupportsMTP(env.Help) && !specUserSet(extra, env.ArgEnv) {
|
|
out = append(out, tuneCand{Stage: "opt-in", Label: "SPEC=auto", Cfg: setKey(cfg, "SPEC", "auto")})
|
|
}
|
|
if mode != "off" && mode != "" && mode != "ngram" && strings.Contains(env.Help, "--spec-draft-n-max") {
|
|
cur := firstNonEmpty(knobSpecN.get(cfg), "3")
|
|
for _, v := range []string{"2", "3", "4"} {
|
|
if v != cur {
|
|
out = append(out, tuneCand{Stage: "opt-in", Label: "SPEC_N_MAX=" + v, Cfg: knobSpecN.set(cfg, v)})
|
|
}
|
|
}
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(cfg["CUDA_GRAPH_OPT"]), "on") && env.UserEnv["GGML_CUDA_GRAPH_OPT"] == "" {
|
|
out = append(out, tuneCand{Stage: "opt-in", Label: "CUDA_GRAPH_OPT=on", Cfg: setKey(cfg, "CUDA_GRAPH_OPT", "on")})
|
|
}
|
|
if strings.Contains(env.Help, "--backend-sampling") && !hasAnyFlag(extra, "-bs", "--backend-sampling") &&
|
|
!envTruthy(env.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"]) && splitModeKey(cfg) != "tensor" {
|
|
out = append(out, tuneCand{Stage: "opt-in", Label: "--backend-sampling",
|
|
Cfg: setKey(cfg, "EXTRA_ARGS", tuneExtraAddSwitch(cfg["EXTRA_ARGS"], "--backend-sampling"))})
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, "rien à essayer"
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// --- garde-fous ----------------------------------------------------------------
|
|
|
|
// tuneCoreArgs : la ligne de commande sans ce que l'optimiseur a le droit de
|
|
// régler. Deux lignes au même cœur ne diffèrent que par l'ordonnancement.
|
|
// Adresse, port, clé d'API, dossier des slots et taille auto du cache RAM (lue
|
|
// sur la RAM libre du moment) sont neutralisés : propres à l'essai ou au
|
|
// moment, sans effet sur le calcul. En opt-in, la spéculation entière
|
|
// (--spec-*, -md) sort aussi du cœur.
|
|
func tuneCoreArgs(args []string, optIn bool) []string {
|
|
out := make([]string, 0, len(args))
|
|
for i := 0; i < len(args); i++ {
|
|
a := args[i]
|
|
name, val, hasEq := strings.Cut(a, "=")
|
|
next := func() string {
|
|
if hasEq {
|
|
return val
|
|
}
|
|
if i+1 < len(args) {
|
|
i++
|
|
return args[i]
|
|
}
|
|
return ""
|
|
}
|
|
switch name {
|
|
case "--host", "--port", "--api-key", "--slot-save-path", "-cram", "--cache-ram":
|
|
next()
|
|
out = append(out, name, "*")
|
|
continue
|
|
}
|
|
if optIn && (strings.HasPrefix(name, "--spec-") || name == "-md" || name == "--model-draft") {
|
|
next()
|
|
continue
|
|
}
|
|
f := tuneFlagFor(name, optIn)
|
|
if f == nil {
|
|
out = append(out, a)
|
|
continue
|
|
}
|
|
if f.arity == 0 {
|
|
continue
|
|
}
|
|
v := next()
|
|
if name == "-ot" || name == "--override-tensor" {
|
|
// Seuls les éléments qui visent les experts sont réglables.
|
|
var rest []string
|
|
for _, el := range strings.Split(v, ",") {
|
|
pat, _, _ := strings.Cut(strings.TrimSpace(el), "=")
|
|
if strings.TrimSpace(el) != "" && !otTargetsExperts(pat) {
|
|
rest = append(rest, strings.TrimSpace(el))
|
|
}
|
|
}
|
|
if len(rest) > 0 {
|
|
out = append(out, name, strings.Join(rest, ","))
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// tuneEnvWhitelist : variables du moteur que l'optimiseur règle.
|
|
func tuneEnvTuned(k string, optIn bool) bool {
|
|
switch k {
|
|
case "CUDA_SCALE_LAUNCH_QUEUES", "GGML_OP_OFFLOAD_MIN_BATCH":
|
|
return true
|
|
case "GGML_CUDA_GRAPH_OPT":
|
|
return optIn
|
|
}
|
|
return false
|
|
}
|
|
|
|
// tuneDenature compare la ligne d'un essai à celle de référence, hors liste
|
|
// blanche. "" = même calcul ; sinon, la première différence, en clair.
|
|
func tuneDenature(base, cand tuneLaunch, optIn bool) string {
|
|
ba, ca := tuneCoreArgs(base.Args, optIn), tuneCoreArgs(cand.Args, optIn)
|
|
for i := 0; i < max(len(ba), len(ca)); i++ {
|
|
var x, y string
|
|
if i < len(ba) {
|
|
x = ba[i]
|
|
}
|
|
if i < len(ca) {
|
|
y = ca[i]
|
|
}
|
|
if x != y {
|
|
return fmt.Sprintf("ligne de commande différente hors réglages permis (« %s » au lieu de « %s »)", y, x)
|
|
}
|
|
}
|
|
keys := map[string]bool{}
|
|
for k := range base.Env {
|
|
keys[k] = true
|
|
}
|
|
for k := range cand.Env {
|
|
keys[k] = true
|
|
}
|
|
for k := range keys {
|
|
if !tuneEnvTuned(k, optIn) && base.Env[k] != cand.Env[k] {
|
|
return fmt.Sprintf("variable %s différente (« %s » au lieu de « %s »)", k, cand.Env[k], base.Env[k])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// tuneLaunchKey : identité d'une ligne de lancement, adresse et port mis à
|
|
// part. Deux essais à la même clé lanceraient exactement le même moteur : le
|
|
// second n'est pas mesuré.
|
|
func tuneLaunchKey(l tuneLaunch) string {
|
|
full := make([]string, 0, len(l.Args))
|
|
for i := 0; i < len(l.Args); i++ {
|
|
switch l.Args[i] {
|
|
case "--host", "--port", "--api-key", "--slot-save-path":
|
|
i++
|
|
continue
|
|
}
|
|
full = append(full, l.Args[i])
|
|
}
|
|
keys := make([]string, 0, len(l.Env))
|
|
for k, v := range l.Env {
|
|
keys = append(keys, k+"="+v)
|
|
}
|
|
sort.Strings(keys)
|
|
return strings.Join(full, "\x00") + "\x01" + strings.Join(keys, "\x00")
|
|
}
|
|
|
|
// tuneCfgCheck : la configuration d'un essai ne diffère de la référence que
|
|
// par des clés permises, et son EXTRA_ARGS que par des drapeaux de la liste
|
|
// blanche — les autres jetons à l'identique, dans l'ordre. Dernier filet avant
|
|
// tout lancement.
|
|
func tuneCfgCheck(base, cand map[string]string, optIn bool) error {
|
|
keys := map[string]bool{}
|
|
for k := range base {
|
|
keys[k] = true
|
|
}
|
|
for k := range cand {
|
|
keys[k] = true
|
|
}
|
|
for k := range keys {
|
|
if base[k] == cand[k] {
|
|
continue
|
|
}
|
|
if k == "EXTRA_ARGS" {
|
|
a, b := tuneCoreArgs(splitArgs(base[k]), optIn), tuneCoreArgs(splitArgs(cand[k]), optIn)
|
|
if strings.Join(a, "\x00") != strings.Join(b, "\x00") {
|
|
return fmt.Errorf("EXTRA_ARGS modifié hors liste blanche")
|
|
}
|
|
continue
|
|
}
|
|
if !tuneKeys[k] && !(optIn && tuneOptInKeys[k]) {
|
|
return fmt.Errorf("clé %s intouchable", k)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tuneDiff : ce qui change d'une configuration à l'autre, clé par clé.
|
|
func tuneDiff(base, cand map[string]string) []string {
|
|
keys := map[string]bool{}
|
|
for k := range base {
|
|
keys[k] = true
|
|
}
|
|
for k := range cand {
|
|
keys[k] = true
|
|
}
|
|
var names []string
|
|
for k := range keys {
|
|
if base[k] != cand[k] {
|
|
names = append(names, k)
|
|
}
|
|
}
|
|
sort.Strings(names)
|
|
show := func(v string) string {
|
|
if v == "" {
|
|
return "(absent)"
|
|
}
|
|
return v
|
|
}
|
|
var out []string
|
|
for _, k := range names {
|
|
out = append(out, k+" : "+show(base[k])+" → "+show(cand[k]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// tunePatchPreset réécrit le preset clé par clé (presetSetKey) : seules les
|
|
// clés réglées bougent, le reste du fichier — commentaires, ordre, NAME —
|
|
// ne change pas. Vérifié : relu, le preset est l'ancien plus les clés réglées,
|
|
// rien d'autre.
|
|
func tunePatchPreset(content string, base, win map[string]string) (string, error) {
|
|
keys := map[string]bool{}
|
|
for k := range base {
|
|
keys[k] = true
|
|
}
|
|
for k := range win {
|
|
keys[k] = true
|
|
}
|
|
var changed []string
|
|
for k := range keys {
|
|
if base[k] != win[k] {
|
|
changed = append(changed, k)
|
|
}
|
|
}
|
|
sort.Strings(changed)
|
|
want := parseEnv(content)
|
|
out := content
|
|
for _, k := range changed {
|
|
if !tuneKeys[k] && !tuneOptInKeys[k] {
|
|
return "", fmt.Errorf("clé %s intouchable", k)
|
|
}
|
|
out = presetSetKey(out, k, win[k])
|
|
if win[k] == "" {
|
|
delete(want, k)
|
|
} else {
|
|
want[k] = win[k]
|
|
}
|
|
}
|
|
got := parseEnv(out)
|
|
if len(got) != len(want) {
|
|
return "", fmt.Errorf("réécriture du preset incohérente")
|
|
}
|
|
for k, v := range want {
|
|
if got[k] != v {
|
|
return "", fmt.Errorf("réécriture du preset incohérente sur %s", k)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// --- journal de l'essai ------------------------------------------------------------
|
|
|
|
var (
|
|
reTuneOffloaded = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`)
|
|
reTuneKV = regexp.MustCompile(`K \(([A-Za-z0-9_]+)\):[^\n]*V \(([A-Za-z0-9_]+)\):`)
|
|
)
|
|
|
|
// tuneLogFacts : couches déportées (dernière mention ; -1 = absente) et types
|
|
// du cache KV vus dans le journal (« f16/f16 », plusieurs caches dédoublonnés).
|
|
func tuneLogFacts(log string) (offloaded, total int, kv string) {
|
|
offloaded, total = -1, -1
|
|
for _, m := range reTuneOffloaded.FindAllStringSubmatch(log, -1) {
|
|
offloaded, _ = strconv.Atoi(m[1])
|
|
total, _ = strconv.Atoi(m[2])
|
|
}
|
|
set := map[string]bool{}
|
|
for _, m := range reTuneKV.FindAllStringSubmatch(log, -1) {
|
|
set[strings.ToLower(m[1])+"/"+strings.ToLower(m[2])] = true
|
|
}
|
|
var kvs []string
|
|
for k := range set {
|
|
kvs = append(kvs, k)
|
|
}
|
|
sort.Strings(kvs)
|
|
return offloaded, total, strings.Join(kvs, ",")
|
|
}
|
|
|
|
// tuneLoadFailure : pourquoi un essai n'a pas chargé, lu dans son journal.
|
|
func tuneLoadFailure(log string) string {
|
|
l := strings.ToLower(log)
|
|
switch {
|
|
case strings.Contains(l, "out of memory") || strings.Contains(l, "cudamalloc failed") ||
|
|
strings.Contains(l, "failed to allocate"):
|
|
return "mémoire insuffisante (OOM)"
|
|
case strings.Contains(l, "failed to fit"):
|
|
return "--fit n'a pas su placer le modèle"
|
|
case strings.Contains(l, "failed to load model") || strings.Contains(l, "error loading model"):
|
|
return "échec du chargement du modèle"
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(log), "\n")
|
|
if n := len(lines); n > 0 && lines[n-1] != "" {
|
|
last := strings.TrimSpace(lines[n-1])
|
|
if len(last) > 160 {
|
|
last = last[:160]
|
|
}
|
|
return "moteur arrêté : " + last
|
|
}
|
|
return "moteur arrêté au chargement"
|
|
}
|
|
|
|
// --- mesure et score -----------------------------------------------------------------
|
|
|
|
// tuneWeights : la forme d'un tour typique. K jetons neufs à lire (cache
|
|
// repris), G jetons à écrire, et une part fCold des tours qui relisent tout à
|
|
// froid à la profondeur D (compaction, changement de discussion).
|
|
type tuneWeights struct {
|
|
K float64 `json:"k"`
|
|
G float64 `json:"g"`
|
|
FCold float64 `json:"f_cold"`
|
|
From string `json:"from"` // « télémétrie » ou « défaut »
|
|
}
|
|
|
|
var tuneDefaultWeights = tuneWeights{K: 2000, G: 600, FCold: 0.05, From: "défaut"}
|
|
|
|
// tuneWeightsFrom tire K, G et fCold des médianes de la télémétrie (perf_log.go)
|
|
// quand elle en a assez vu ; sinon les valeurs par défaut.
|
|
func tuneWeightsFrom(recs []perfRec) tuneWeights {
|
|
var news, gens []float64
|
|
cold, known := 0, 0
|
|
for _, r := range recs {
|
|
if !r.Complete {
|
|
continue
|
|
}
|
|
switch r.Kind {
|
|
case perfBench, perfPrewarm, perfForeign:
|
|
continue
|
|
}
|
|
if r.New > 0 {
|
|
news = append(news, float64(r.New))
|
|
}
|
|
if r.Gen > 0 {
|
|
gens = append(gens, float64(r.Gen))
|
|
}
|
|
if r.Cached != nil && r.Total >= 2048 {
|
|
known++
|
|
if *r.Cached*10 < r.Total {
|
|
cold++
|
|
}
|
|
}
|
|
}
|
|
if len(news) < 20 || len(gens) < 20 {
|
|
return tuneDefaultWeights
|
|
}
|
|
w := tuneWeights{K: *perfMedian(news), G: *perfMedian(gens), FCold: tuneDefaultWeights.FCold, From: "télémétrie"}
|
|
w.K = min(max(w.K, 128), 32768)
|
|
w.G = min(max(w.G, 16), 8192)
|
|
if known >= 20 {
|
|
w.FCold = float64(cold) / float64(known)
|
|
}
|
|
return w
|
|
}
|
|
|
|
// tuneRun : une mesure d'un essai (un passage du bench complet).
|
|
type tuneRun struct {
|
|
TurnSec float64 `json:"turn_sec"` // durée estimée d'un tour : le score, plus bas = mieux
|
|
ColdPP float64 `json:"cold_pp,omitempty"` // prefill à froid à D (t/s)
|
|
CachedPP float64 `json:"cached_pp,omitempty"` // jetons neufs par seconde des tours, reprise réelle comprise
|
|
TG float64 `json:"tg,omitempty"` // decode à D (code)
|
|
ProsePP float64 `json:"prose_pp"`
|
|
ProseTG float64 `json:"prose_tg"` // decode de la ligne courte (prose)
|
|
Depth int `json:"depth,omitempty"`
|
|
}
|
|
|
|
// tuneRunFrom réduit un bench complet à son score. Le prefill des tours compte
|
|
// le temps RÉELLEMENT passé pour leurs jetons neufs : un hybride dont le
|
|
// micro-lot déplace les points de reprise relit plus de contexte, et le paie.
|
|
func tuneRunFrom(res *benchResult, w tuneWeights) (tuneRun, error) {
|
|
if res == nil || res.PredictedPerSec <= 0 || res.PromptPerSecond <= 0 {
|
|
return tuneRun{}, fmt.Errorf("mesure vide")
|
|
}
|
|
r := tuneRun{ProsePP: res.PromptPerSecond, ProseTG: res.PredictedPerSec}
|
|
d := res.Depth
|
|
if d == nil || d.Skipped != "" {
|
|
r.TurnSec = w.K/r.ProsePP + w.G/r.ProseTG
|
|
return r, nil
|
|
}
|
|
if d.Partial != "" {
|
|
return tuneRun{}, fmt.Errorf("mesure partielle : %s", d.Partial)
|
|
}
|
|
if d.Cold == nil || d.Cold.PromptPerSecond <= 0 || len(d.Turns) == 0 || d.DecodePerSec <= 0 {
|
|
return tuneRun{}, fmt.Errorf("mesure en profondeur incomplète")
|
|
}
|
|
var fresh int
|
|
var ms float64
|
|
for _, t := range d.Turns {
|
|
if t.Cached < 0 || t.Expected <= 0 {
|
|
fresh, ms = 0, 0
|
|
break
|
|
}
|
|
fresh += t.New + t.Cached - t.Expected
|
|
ms += t.PromptMs
|
|
}
|
|
r.CachedPP = d.CachedPerSec
|
|
if fresh > 0 && ms > 0 {
|
|
r.CachedPP = float64(fresh) / (ms / 1000)
|
|
}
|
|
if r.CachedPP <= 0 {
|
|
return tuneRun{}, fmt.Errorf("prefill des tours illisible")
|
|
}
|
|
r.ColdPP, r.TG, r.Depth = d.Cold.PromptPerSecond, d.DecodePerSec, d.Target
|
|
r.TurnSec = w.K/r.CachedPP + w.G/r.TG + w.FCold*float64(d.Target)/r.ColdPP
|
|
return r, nil
|
|
}
|
|
|
|
func tuneMean(v []float64) float64 {
|
|
if len(v) == 0 {
|
|
return 0
|
|
}
|
|
s := 0.0
|
|
for _, x := range v {
|
|
s += x
|
|
}
|
|
return s / float64(len(v))
|
|
}
|
|
|
|
// tuneSpread : l'écart relatif entre passages ((max-min)/moyenne).
|
|
func tuneSpread(v []float64) float64 {
|
|
if len(v) < 2 {
|
|
return 0
|
|
}
|
|
lo, hi := math.Inf(1), math.Inf(-1)
|
|
for _, x := range v {
|
|
lo, hi = min(lo, x), max(hi, x)
|
|
}
|
|
m := tuneMean(v)
|
|
if m <= 0 {
|
|
return 0
|
|
}
|
|
return (hi - lo) / m
|
|
}
|
|
|
|
// tuneMinGain : en dessous de 3 %, un gain se confond avec le bruit.
|
|
const tuneMinGain = 0.03
|
|
|
|
// tuneWins : le candidat (durées de tour, en secondes) bat-il la référence ?
|
|
// Seulement d'au moins max(3 %, l'écart entre passages de l'un ou de l'autre),
|
|
// et sur deux passages : un seul bon passage peut être un coup de chance.
|
|
func tuneWins(base, cand []float64) (gain, need float64, ok bool) {
|
|
mb := tuneMean(base)
|
|
if mb <= 0 || len(cand) == 0 {
|
|
return 0, tuneMinGain, false
|
|
}
|
|
gain = (mb - tuneMean(cand)) / mb
|
|
need = max(tuneMinGain, tuneSpread(base), tuneSpread(cand))
|
|
return gain, need, len(cand) >= 2 && gain > need
|
|
}
|
|
|
|
// tuneWorthRepeat : un premier passage assez prometteur pour en mériter un
|
|
// second (sinon on passe au suivant, le budget est compté).
|
|
func tuneWorthRepeat(base []float64, first float64) bool {
|
|
mb := tuneMean(base)
|
|
return mb > 0 && (mb-first)/mb > tuneMinGain
|
|
}
|
|
|
|
// tuneTightMiB : marge VRAM minimale par carte, après le bench, pour qu'un
|
|
// essai puisse gagner. Ce que le bench n'exerce pas — encodage d'images du
|
|
// projecteur vision, autres conteneurs — vit dans cette marge.
|
|
const tuneTightMiB = 768
|
|
|
|
// tuneVisionReserveMiB : réserve de plus quand MMPROJ est posé (tampons de
|
|
// l'encodeur d'images, que le bench texte n'alloue pas). Estimation.
|
|
const tuneVisionReserveMiB = 512
|
|
|
|
// tuneHeadroom : VRAM libre minimale sur les cartes visibles (Mio). visible :
|
|
// index nvidia-smi des cartes du moteur ; vide = toutes. ok=false sans mesure.
|
|
func tuneHeadroom(gpus []gpuStat, visible []int) (minFree int, ok bool) {
|
|
minFree = math.MaxInt
|
|
for i, g := range gpus {
|
|
if len(visible) > 0 && !containsInt(visible, i) {
|
|
continue
|
|
}
|
|
if g.Total <= 0 {
|
|
continue
|
|
}
|
|
minFree = min(minFree, g.Total-g.Used)
|
|
ok = true
|
|
}
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return minFree, true
|
|
}
|
|
|
|
func containsInt(v []int, x int) bool {
|
|
for _, y := range v {
|
|
if y == x {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// tuneVisibleGPUs : index des cartes de CUDA_VISIBLE_DEVICES (ordre PCI, celui
|
|
// de nvidia-smi). Vide = toutes, ou des UUID qu'on ne sait pas relier.
|
|
func tuneVisibleGPUs(cfg map[string]string) []int {
|
|
var out []int
|
|
for _, p := range strings.Split(cfg["CUDA_VISIBLE_DEVICES"], ",") {
|
|
n, err := strconv.Atoi(strings.TrimSpace(p))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- fichiers de l'essai -------------------------------------------------------------
|
|
|
|
// tuneLaunch : la ligne de commande composée par « loki serve » pour un essai.
|
|
type tuneLaunch struct {
|
|
Bin string `json:"bin"`
|
|
Args []string `json:"args"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
Notes []string `json:"notes,omitempty"`
|
|
Probe *tuneProbe `json:"probe,omitempty"`
|
|
}
|
|
|
|
// tuneProbe : ce que « loki serve » a sondé pour composer la ligne — dans le
|
|
// contexte exact du moteur (bibliothèques, cartes visibles, environnement).
|
|
// L'optimiseur s'en sert pour choisir ses essais.
|
|
type tuneProbe struct {
|
|
Help string `json:"help"`
|
|
GPUs int `json:"gpus"`
|
|
VRAMMiB int64 `json:"vram_mib"`
|
|
ModelBytes int64 `json:"model_bytes"`
|
|
MoE bool `json:"moe"`
|
|
CPUThreads int `json:"cpu_threads"` // sonde de conteneur (cpusetThreads) ; 0 = rien à dire
|
|
ArgEnv map[string]string `json:"arg_env,omitempty"`
|
|
UserEnv map[string]string `json:"user_env,omitempty"`
|
|
QueuesEnv bool `json:"queues_env"`
|
|
}
|
|
|
|
// tuneProbeFrom : la sonde, tirée de ce que cmdServe a relevé.
|
|
func tuneProbeFrom(si serveSysInfo) *tuneProbe {
|
|
return &tuneProbe{Help: si.Help, GPUs: si.GPUs, VRAMMiB: si.VRAMMiB, ModelBytes: si.ModelBytes,
|
|
MoE: si.GGUF != nil && si.GGUF.ExpertCount > 0, CPUThreads: si.CPU.N,
|
|
ArgEnv: si.ArgEnv, UserEnv: si.UserEnv, QueuesEnv: si.LaunchQueues != ""}
|
|
}
|
|
|
|
// writeTuneLaunch écrit la ligne de l'essai, clé d'API masquée.
|
|
func writeTuneLaunch(dir string, l tuneLaunch) error {
|
|
args := append([]string(nil), l.Args...)
|
|
for i := 0; i+1 < len(args); i++ {
|
|
if args[i] == "--api-key" {
|
|
args[i+1] = "***"
|
|
}
|
|
}
|
|
l.Args = args
|
|
b, err := json.MarshalIndent(l, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(filepath.Join(dir, "launch.json"), b, 0o600)
|
|
}
|
|
|
|
func readTuneLaunch(dir string) (tuneLaunch, error) {
|
|
var l tuneLaunch
|
|
b, err := os.ReadFile(filepath.Join(dir, "launch.json"))
|
|
if err != nil {
|
|
return l, err
|
|
}
|
|
return l, json.Unmarshal(b, &l)
|
|
}
|
|
|
|
// writeTuneTrialConfig pose la configuration d'un essai dans son dossier — une
|
|
// copie, jamais config.env. Relue avant d'être confiée au moteur : une valeur
|
|
// que le format ne sait pas représenter serait un autre essai que celui voulu.
|
|
func writeTuneTrialConfig(dir string, cfg map[string]string) error {
|
|
text := formatEnv(cfg)
|
|
back := parseEnv(text)
|
|
if len(back) != len(cfg) {
|
|
return fmt.Errorf("configuration d'essai non représentable")
|
|
}
|
|
for k, v := range cfg {
|
|
if back[k] != v {
|
|
return fmt.Errorf("valeur de %s non représentable dans un fichier de configuration", k)
|
|
}
|
|
}
|
|
return os.WriteFile(filepath.Join(dir, "config.env"), []byte(text), 0o600)
|
|
}
|
|
|
|
func readTuneTrialConfig(dir string) (map[string]string, error) {
|
|
b, err := os.ReadFile(filepath.Join(dir, "config.env"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("configuration d'essai illisible : %w", err)
|
|
}
|
|
return parseEnv(string(b)), nil
|
|
}
|
|
|
|
// tuneForceAddr : toutes les occurrences de --host / --port pointent sur
|
|
// l'adresse de l'essai (EXTRA_ARGS pourrait en nommer d'autres, et la dernière
|
|
// gagne).
|
|
func tuneForceAddr(args []string, host, port string) []string {
|
|
out := append([]string(nil), args...)
|
|
for i := 0; i < len(out); i++ {
|
|
name, _, hasEq := strings.Cut(out[i], "=")
|
|
val := ""
|
|
switch name {
|
|
case "--host":
|
|
val = host
|
|
case "--port":
|
|
val = port
|
|
default:
|
|
continue
|
|
}
|
|
if hasEq {
|
|
out[i] = name + "=" + val
|
|
} else if i+1 < len(out) {
|
|
out[i+1] = val
|
|
i++
|
|
}
|
|
}
|
|
return out
|
|
}
|