From 9aa426a389ffadaf09e20a124c92f4f30e246f7e Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sat, 3 Oct 2026 22:53:44 +0200 Subject: [PATCH] =?UTF-8?q?Moteur=20:=20trois=20r=C3=A9glages=20d'expert?= =?UTF-8?q?=20sur=20demande=20=E2=80=94=20marge=20VRAM=20par=20carte=20(FI?= =?UTF-8?q?T=5FTARGET),=20seuil=20d'offload=20des=20experts,=20branches=20?= =?UTF-8?q?Q/K/V=20parall=C3=A8les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur deux cartes inégales (5060 Ti + 3060) ou un MoE aux experts sur CPU, il reste quelques leviers de placement que llama.cpp expose mais que Loki ne savait pas poser. Aucun ne touche au modèle (poids, contexte, cache, échantillonnage) ; tous restent éteints tant que le preset ne les demande pas, et le gain se décide à la mesure. - FIT_TARGET=1024,3072 → --fit-target : marge libre par carte pour --fit. Validée (entiers, 8 valeurs au plus, jamais transmise brute : stoull ferait mourir le moteur en boucle), remontée à 1024 Mio au minimum. Seulement si l'aide la liste, sans -fitt ni LLAMA_ARG_FIT_TARGET déjà posés, avec un contexte chiffré (CTX=0 laisserait fit réduire le contexte) et si --fit tournera vraiment : NGL chiffré, --tensor-split, -ot, --n-cpu-moe, -cmoe, --fit off ou -sm row le coupent, et on le dit au lieu de ne rien faire. - OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : les lots de moins de N jetons restent sur CPU pour les poids en RAM. Utile pour des brouillons ngram de 32 jetons ou plus ; MTP vérifie déjà sous 32. - CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : expérimental, sortie identique en amont, gain de 0 à quelques % ; refusé si -sm row/tensor ou un -ot sur l'attention pouvait séparer Q, K et V d'une couche. Le lancement rappelle « off puis redémarrer » en cas de GGML_ASSERT. - une variable déjà dans l'environnement n'est jamais touchée ; elles ne vont qu'à llama-server via « loki serve ». - tensorOverride, extrait de pipelineBlocker, sert aussi à la garde de --fit. - --backend-sampling écarté : pas identique au bit près, et inactif sur les requêtes à outils (grammaire) qui font l'essentiel du trafic. - clés documentées dans le squelette de « loki edit » ; tests de table. Co-Authored-By: Claude Opus 5.5 --- internal/loki/backend_serve.go | 20 +- internal/loki/backend_serve_expert.go | 263 +++++++++++++++++++++ internal/loki/backend_serve_expert_test.go | 207 ++++++++++++++++ internal/loki/backend_serve_queues.go | 37 ++- internal/loki/sys_service.go | 6 + 5 files changed, 519 insertions(+), 14 deletions(-) create mode 100644 internal/loki/backend_serve_expert.go create mode 100644 internal/loki/backend_serve_expert_test.go diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 4251c9c..66cf7ec 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -340,6 +340,7 @@ func cmdServe(args []string) error { // Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir // launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse. probeServeGPUs(cfg, extra, &si) + probeExpertEnv(&si) llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si) applyServeEnv(env) @@ -381,7 +382,8 @@ type serveSysInfo struct { LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé - ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline (voir serveArgEnv) + ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline et de --fit (serveArgEnv, fitArgEnv) + UserEnv map[string]string // GGML_* des réglages d'expert déjà posés (voir expertEnvKeys) : intouchés } // cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par @@ -450,6 +452,17 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS env["CUDA_SCALE_LAUNCH_QUEUES"] = q } notes = append(notes, qNotes...) + // Réglages d'expert passés par variable (voir backend_serve_expert.go) : + // absents du preset, ils ne posent rien. + g, gNotes := graphOptEnv(cfg, extra, si) + if g != "" { + env["GGML_CUDA_GRAPH_OPT"] = g + } + o, oNotes := opOffloadMinBatchEnv(cfg, si) + if o != "" { + env["GGML_OP_OFFLOAD_MIN_BATCH"] = o + } + notes = append(append(notes, gNotes...), oNotes...) // Threads : vide ou 0 = AUCUN drapeau, pour que llama.cpp prenne ses cœurs // physiques au lieu de tous les threads logiques (voir threadArgs). @@ -504,6 +517,11 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS } args = append(args, ngl...) } + // Marge VRAM par carte du placement automatique (FIT_TARGET, voir + // fitTargetArgs) : seulement si --fit tournera vraiment. + fitt, fitNotes := fitTargetArgs(cfg, extra, si) + args = append(args, fitt...) + notes = append(notes, fitNotes...) if ktv != "" { args = append(args, "-ctk", ktv) } diff --git a/internal/loki/backend_serve_expert.go b/internal/loki/backend_serve_expert.go new file mode 100644 index 0000000..c6350be --- /dev/null +++ b/internal/loki/backend_serve_expert.go @@ -0,0 +1,263 @@ +package loki + +import ( + "os" + "strconv" + "strings" +) + +// Réglages d'expert du moteur local, tous éteints tant que le preset ne les +// demande pas. Aucun ne touche au modèle : ni poids, ni contexte, ni +// échantillonnage — seulement OÙ et COMMENT le même calcul s'exécute. +// +// FIT_TARGET=1024,3072 → --fit-target : marge VRAM laissée libre par carte +// OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : seuil d'envoi au GPU +// des poids restés en RAM +// CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : branches Q/K/V en parallèle +// +// Une variable déjà présente dans l'environnement (gabarit Docker/Unraid) est +// un choix de l'utilisateur : jamais touchée. Les variables passent par +// l'environnement du seul « loki serve », qui devient llama-server : le reste +// du conteneur ne les voit pas. + +// expertEnvKeys : les variables du moteur que ces réglages posent. +var expertEnvKeys = []string{"GGML_CUDA_GRAPH_OPT", "GGML_OP_OFFLOAD_MIN_BATCH"} + +// fitArgEnv : les LLAMA_ARG_* qui, sans drapeau, décident de --fit et de sa +// marge. llama.cpp les applique quand la ligne de commande se tait. +var fitArgEnv = []string{"LLAMA_ARG_FIT", "LLAMA_ARG_FIT_TARGET", "LLAMA_ARG_TENSOR_SPLIT"} + +// probeExpertEnv relève ce que l'environnement a déjà tranché. Appelée après +// probeServeGPUs, dont elle complète ArgEnv. +func probeExpertEnv(si *serveSysInfo) { + si.UserEnv = map[string]string{} + for _, k := range expertEnvKeys { + if v := os.Getenv(k); v != "" { + si.UserEnv[k] = v + } + } + if si.ArgEnv == nil { + si.ArgEnv = map[string]string{} + } + for _, k := range fitArgEnv { + if v := os.Getenv(k); v != "" { + si.ArgEnv[k] = v + } + } +} + +// fitTargetMin : la marge par défaut de llama.cpp (common.h), en Mio. En +// dessous, fit range plus serré que ce qu'il sait estimer — le tampon du modèle +// MTP, qu'il ne mesure pas toujours, suffit alors à ramener le « cudaMalloc +// failed: out of memory » au premier long prompt. On remonte, en le disant. +const fitTargetMin = 1024 + +// fitTargetMaxValues : llama.cpp refuse (et le serveur meurt en boucle) une +// liste aussi longue que son nombre maximal de cartes ; 8 reste loin du bord. +const fitTargetMaxValues = 8 + +// parseFitTarget valide FIT_TARGET : des entiers en Mio séparés par « , » ou +// « / », une valeur par carte dans l'ordre que voit le moteur (une seule vaut +// pour toutes). Jamais transmis brut : llama.cpp lit chaque valeur avec +// std::stoull, qui lève sur le moindre caractère parasite — moteur mort au +// démarrage, relancé en boucle par systemd ou Docker. +func parseFitTarget(v string) (vals []int, raised bool, ok bool) { + parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '/' }) + if len(parts) == 0 || len(parts) > fitTargetMaxValues || + strings.Count(v, ",")+strings.Count(v, "/") != len(parts)-1 { + return nil, false, false + } + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || strings.Trim(p, "0123456789") != "" || len(p) > 7 { + return nil, false, false + } + n, _ := strconv.Atoi(p) + if n < fitTargetMin { + n, raised = fitTargetMin, true + } + vals = append(vals, n) + } + return vals, raised, true +} + +// fitTargetArgs traduit FIT_TARGET en --fit-target. Fonction pure. +// +// La marge règle combien de VRAM --fit laisse libre sur chaque carte ; sur deux +// cartes inégales (5060 Ti + 3060), une marge plus large sur la lente pousse +// plus de couches vers la rapide. Placement seulement : mêmes poids, mêmes +// types de cache, même contexte. Rien n'est posé quand : +// +// - le moteur ne connaît pas --fit-target (un drapeau inconnu le tue) ; +// - EXTRA_ARGS ou LLAMA_ARG_FIT_TARGET l'ont déjà fixée ; +// - le contexte n'est pas un nombre positif : avec -c 0, fit a le droit de +// RÉDUIRE le contexte pour tenir, et une marge plus large l'y pousserait +// sans un mot — de l'information perdue pour le modèle ; +// - --fit ne tournera pas (voir fitBlocker) : la clé ne ferait rien, et une +// comparaison A/B mesurerait deux fois le même placement. +func fitTargetArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) { + raw := strings.TrimSpace(cfg["FIT_TARGET"]) + if raw == "" { + return nil, nil + } + ignore := func(why string) (a, n []string) { + return nil, []string{"FIT_TARGET=" + raw + " ignoré : " + why} + } + vals, raised, ok := parseFitTarget(raw) + switch { + case !ok: + return ignore("des Mio par carte, ex. 1024,3072 (8 valeurs au plus)") + case !strings.Contains(si.Help, "--fit-target"): + return ignore("ce moteur ne connaît pas --fit-target") + case hasAnyFlag(extra, "-fitt", "--fit-target"): + return ignore("--fit-target déjà dans EXTRA_ARGS") + case si.ArgEnv["LLAMA_ARG_FIT_TARGET"] != "": + return ignore("LLAMA_ARG_FIT_TARGET déjà posé dans l'environnement") + } + ctx := flagValue(extra, "-c", "--ctx-size") + if ctx == "" { + ctx = strings.TrimSpace(cfg["CTX"]) + if ctx == "" { + ctx = "32768" + } + } + if n, err := strconv.Atoi(ctx); err != nil || n <= 0 { + return ignore("contexte « " + ctx + " » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré") + } + if why := fitBlocker(cfg, extra, si); why != "" { + return ignore("--fit inactif (" + why + "), la marge ne servirait à rien") + } + s := make([]string, len(vals)) + for i, n := range vals { + s[i] = strconv.Itoa(n) + } + v := strings.Join(s, ",") + note := "--fit-target " + v + " (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — " + + "vérifie « offloaded N/N layers » dans le journal" + if raised { + note += " ; marges sous 1024 Mio remontées à 1024" + } + return []string{"--fit-target", v}, []string{note} +} + +// fitBlocker dit pourquoi llama.cpp ne lancera PAS --fit (common/fit.cpp : +// « already set by user, abort »), ou vide s'il le lancera. Les couches GPU +// sont lues comme buildServeArgs les écrira : EXTRA_ARGS d'abord, sinon la +// traduction de NGL par nglArgs (999 y devient auto), sinon — NGL=auto, aucun +// drapeau — la variable du moteur. +func fitBlocker(cfg map[string]string, extra []string, si serveSysInfo) string { + fit := flagValue(extra, "-fit", "--fit") + if fit == "" { + fit = si.ArgEnv["LLAMA_ARG_FIT"] + } + switch strings.ToLower(strings.TrimSpace(fit)) { + case "off", "disabled", "false", "0": + return "--fit off" + } + ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") + if ngl == "" { + if a, _ := nglArgs(cfg["NGL"], helpFitsLayersItself(si.Help)); len(a) == 2 { + ngl = a[1] + } else { + ngl = si.ArgEnv["LLAMA_ARG_N_GPU_LAYERS"] + } + } + if ngl = strings.TrimSpace(ngl); ngl != "" && !strings.EqualFold(ngl, "auto") { + return "couches GPU fixées à " + ngl + } + if hasAnyFlag(extra, "-ts", "--tensor-split") || si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] != "" { + return "--tensor-split" + } + if r := tensorOverride(extra, si.ArgEnv); r != "" { + return r + } + sm := flagValue(extra, "-sm", "--split-mode") + if sm == "" { + sm = si.ArgEnv["LLAMA_ARG_SPLIT_MODE"] + } + if strings.EqualFold(strings.TrimSpace(sm), "row") { + return "-sm row" + } + return "" +} + +// opOffloadMinBatchEnv traduit OP_OFFLOAD_MIN_BATCH en GGML_OP_OFFLOAD_MIN_BATCH. +// Fonction pure. +// +// Quand des poids restent en RAM (experts MoE sur CPU), llama.cpp recopie vers +// le GPU ceux dont un lot a besoin dès qu'il compte au moins 32 jetons ; en +// dessous, il calcule sur CPU. Monter le seuil garde sur CPU les lots plus +// courts : utile seulement si une spéculation ngram propose des brouillons de +// 32 jetons ou plus (les lots de vérification MTP, quelques jetons, restent +// déjà sous 32). Le prix : un tour d'outil de 32 à N-1 jetons se calcule lui +// aussi sur CPU. N se trouve à la mesure, pas d'office. Même distribution ; +// les logits ne sont pas identiques au bit près, comme déjà entre décodage CPU +// et prefill GPU. +func opOffloadMinBatchEnv(cfg map[string]string, si serveSysInfo) (val string, notes []string) { + raw := strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"]) + if raw == "" || si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" { + return "", nil + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > 1<<20 { + return "", []string{"OP_OFFLOAD_MIN_BATCH=" + raw + " ignoré : un nombre de jetons, ex. 128"} + } + v := strconv.Itoa(n) + return v, []string{"GGML_OP_OFFLOAD_MIN_BATCH=" + v + " (OP_OFFLOAD_MIN_BATCH) : lots de moins de " + v + + " jetons calculés sur CPU pour les poids restés en RAM"} +} + +// graphOptEnv traduit CUDA_GRAPH_OPT=on en GGML_CUDA_GRAPH_OPT=1. Fonction pure. +// +// Au décodage, llama.cpp lance alors les trois branches Q, K et V de chaque +// couche sur des flux CUDA concurrents. Chaque opération garde son noyau et ses +// entrées : sortie identique au jeton près en amont (PR #28198, perplexité +// inchangée). Gain modeste — quelques % sur un MoE tout GPU, rien sur un dense, +// dilué quand les experts tournent sur CPU ou que MTP vérifie par lots. Sans +// effet avant #28198 en multi-GPU ou si les graphes CUDA sont coupés. +// +// Expérimental : sur une architecture qu'il n'a pas vue, le repérage des +// branches peut s'arrêter sur un GGML_ASSERT, et le moteur redémarre en boucle. +// Une variable ne se détecte pas dans l'aide du moteur : on le dit au +// lancement, pour que « CUDA_GRAPH_OPT=off puis redémarrer » vienne à l'esprit. +// +// Refusé quand Q, K et V d'une même couche pourraient finir sur deux cartes +// (-sm row/tensor, -ot visant l'attention) : #28198 en fait une condition +// d'exactitude. +func graphOptEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) { + raw := strings.TrimSpace(cfg["CUDA_GRAPH_OPT"]) + switch strings.ToLower(raw) { + case "", "off": + return "", nil + case "on": + default: + return "", []string{"CUDA_GRAPH_OPT=" + raw + " ignoré (on ou off)"} + } + if si.UserEnv["GGML_CUDA_GRAPH_OPT"] != "" { + return "", nil + } + if why := qkvSplitRisk(extra, si.ArgEnv); why != "" { + return "", []string{"CUDA_GRAPH_OPT ignoré : " + why + " peut séparer Q, K et V d'une couche entre cartes"} + } + return "1", []string{"GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un " + + "GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre"} +} + +// qkvSplitRisk : un placement qui pourrait répartir les tenseurs d'attention +// d'une même couche sur plusieurs appareils. La découpe par couches (défaut, +// --tensor-split compris) garde chaque couche entière : rien à craindre. +func qkvSplitRisk(extra []string, argEnv map[string]string) string { + sm := flagValue(extra, "-sm", "--split-mode") + if sm == "" { + sm = argEnv["LLAMA_ARG_SPLIT_MODE"] + } + if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" { + return "-sm " + sm + } + for _, v := range append(flagValues(extra, "-ot", "--override-tensor"), argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]) { + if strings.Contains(strings.ToLower(v), "attn") { + return "-ot sur l'attention" + } + } + return "" +} diff --git a/internal/loki/backend_serve_expert_test.go b/internal/loki/backend_serve_expert_test.go new file mode 100644 index 0000000..5a1c213 --- /dev/null +++ b/internal/loki/backend_serve_expert_test.go @@ -0,0 +1,207 @@ +package loki + +import ( + "reflect" + "testing" +) + +// helpFit : un moteur récent qui connaît --fit et sa marge. +const helpFit = helpRecent + `-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 (default: 1024) +` + +// TestFitTargetArgs : --fit-target n'est passé que validé, sur un moteur qui le +// connaît, avec un contexte fixé, et quand --fit tournera vraiment. +func TestFitTargetArgs(t *testing.T) { + fit := serveSysInfo{Help: helpFit} + cases := []struct { + name string + cfg map[string]string + si serveSysInfo + want []string + wantNotes int + }{ + {name: "clé absente : rien, sans un mot", + cfg: map[string]string{}, si: fit}, + {name: "deux marges : passées, et on le dit", + cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: fit, + want: []string{"--fit-target", "1024,3072"}, wantNotes: 1}, + {name: "séparateur / : normalisé en virgules", + cfg: map[string]string{"FIT_TARGET": "2048/1536"}, si: fit, + want: []string{"--fit-target", "2048,1536"}, wantNotes: 1}, + {name: "une seule valeur : vaut pour toutes les cartes", + cfg: map[string]string{"FIT_TARGET": " 2048 "}, si: fit, + want: []string{"--fit-target", "2048"}, wantNotes: 1}, + {name: "marge sous le défaut : remontée à 1024", + cfg: map[string]string{"FIT_TARGET": "256,3072"}, si: fit, + want: []string{"--fit-target", "1024,3072"}, wantNotes: 1}, + {name: "valeur non numérique : jamais transmise (stoull ferait mourir le moteur)", + cfg: map[string]string{"FIT_TARGET": "1G,2G"}, si: fit, wantNotes: 1}, + {name: "valeur négative : refusée", + cfg: map[string]string{"FIT_TARGET": "-1024"}, si: fit, wantNotes: 1}, + {name: "séparateur doublé : refusé", + cfg: map[string]string{"FIT_TARGET": "1024,,2048"}, si: fit, wantNotes: 1}, + {name: "trop de valeurs : refusé", + cfg: map[string]string{"FIT_TARGET": "1,2,3,4,5,6,7,8,9"}, si: fit, wantNotes: 1}, + {name: "moteur sans --fit-target : rien, le drapeau le tuerait", + cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: serveSysInfo{Help: helpRecent}, wantNotes: 1}, + {name: "-fitt déjà dans EXTRA_ARGS : l'utilisateur a tranché", + cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-fitt 2048"}, si: fit, wantNotes: 1}, + {name: "LLAMA_ARG_FIT_TARGET dans l'environnement : intouché", + cfg: map[string]string{"FIT_TARGET": "1024"}, + si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT_TARGET": "4096"}}, wantNotes: 1}, + {name: "CTX=0 : fit pourrait réduire le contexte, refusé", + cfg: map[string]string{"FIT_TARGET": "1024,3072", "CTX": "0"}, si: fit, wantNotes: 1}, + {name: "-c 0 dans EXTRA_ARGS l'emporte sur CTX : refusé", + cfg: map[string]string{"FIT_TARGET": "1024", "CTX": "65536", "EXTRA_ARGS": "-c 0"}, si: fit, wantNotes: 1}, + {name: "CTX absent : Loki passe -c 32768, accepté", + cfg: map[string]string{"FIT_TARGET": "1024"}, si: fit, + want: []string{"--fit-target", "1024"}, wantNotes: 1}, + {name: "NGL=999 : devient -ngl auto, fit tourne", + cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "999"}, si: fit, + want: []string{"--fit-target", "1024"}, wantNotes: 1}, + {name: "NGL chiffré : fit abandonne, rien", + cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40"}, si: fit, wantNotes: 1}, + {name: "NGL=all : fit abandonne, rien", + cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "all"}, si: fit, wantNotes: 1}, + {name: "-ngl auto dans EXTRA_ARGS : fit tourne", + cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40", "EXTRA_ARGS": "-ngl auto"}, si: fit, + want: []string{"--fit-target", "1024"}, wantNotes: 1}, + {name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=30 : rien", + cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "auto"}, + si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "30"}}, wantNotes: 1}, + {name: "--tensor-split : rien", + cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--tensor-split 0.965,0.035"}, si: fit, wantNotes: 1}, + {name: "preset MoE (-ot, --n-cpu-moe) : rien", + cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`}, + si: fit, wantNotes: 1}, + {name: "--cpu-moe : rien", + cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-cmoe"}, si: fit, wantNotes: 1}, + {name: "--fit off : rien", + cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--fit off"}, si: fit, wantNotes: 1}, + {name: "LLAMA_ARG_FIT=off : rien", + cfg: map[string]string{"FIT_TARGET": "1024"}, + si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT": "off"}}, wantNotes: 1}, + {name: "-sm row : rien", + cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm row"}, si: fit, wantNotes: 1}, + {name: "--device choisi : le placement reste à fit, accepté", + cfg: map[string]string{"FIT_TARGET": "1024,3072", "EXTRA_ARGS": "--device CUDA1,CUDA0"}, si: fit, + want: []string{"--fit-target", "1024,3072"}, wantNotes: 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, notes := fitTargetArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si) + if !reflect.DeepEqual(got, c.want) { + t.Fatalf("args = %q, attendu %q", got, c.want) + } + if len(notes) != c.wantNotes { + t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes) + } + }) + } +} + +// TestGraphOptEnv : GGML_CUDA_GRAPH_OPT=1 seulement sur demande, jamais +// par-dessus l'environnement, jamais quand Q/K/V pourraient changer de carte. +func TestGraphOptEnv(t *testing.T) { + cases := []struct { + name string + cfg map[string]string + si serveSysInfo + want string + wantNotes int + }{ + {name: "clé absente : rien", cfg: map[string]string{}}, + {name: "off : rien", cfg: map[string]string{"CUDA_GRAPH_OPT": "off"}}, + {name: "on : posé, et on le dit", cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, want: "1", wantNotes: 1}, + {name: "ON (casse libre) : posé", cfg: map[string]string{"CUDA_GRAPH_OPT": "ON"}, want: "1", wantNotes: 1}, + {name: "valeur inconnue : ignorée, dite", cfg: map[string]string{"CUDA_GRAPH_OPT": "2"}, wantNotes: 1}, + {name: "variable déjà posée (même à 0) : intouchée", + cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, + si: serveSysInfo{UserEnv: map[string]string{"GGML_CUDA_GRAPH_OPT": "0"}}}, + {name: "découpe par couches + --tensor-split : couches entières, posé", + cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "--device CUDA1,CUDA0 -ts 0.6,0.4"}, + want: "1", wantNotes: 1}, + {name: "-sm row : refusé", + cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "-sm row"}, wantNotes: 1}, + {name: "LLAMA_ARG_SPLIT_MODE=tensor : refusé", + cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, + si: serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1}, + {name: "-ot sur l'attention : refusé", + cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "blk\.1\.attn_k.*=CUDA1"`}, wantNotes: 1}, + {name: "-ot des experts seulement : posé", + cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "ffn_.*_exps=CPU" --n-cpu-moe 40`}, + want: "1", wantNotes: 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, notes := graphOptEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si) + if got != c.want { + t.Fatalf("valeur = %q, attendu %q", got, c.want) + } + if len(notes) != c.wantNotes { + t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes) + } + }) + } +} + +// TestOpOffloadMinBatchEnv : la variable n'existe que si la clé est posée et +// valide, et ne remplace jamais celle de l'environnement. +func TestOpOffloadMinBatchEnv(t *testing.T) { + cases := []struct { + name string + cfg map[string]string + si serveSysInfo + want string + wantNotes int + }{ + {name: "clé absente : rien", cfg: map[string]string{}}, + {name: "128 : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"}, want: "128", wantNotes: 1}, + {name: "espaces autour : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": " 64 "}, want: "64", wantNotes: 1}, + {name: "0 : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "0"}, wantNotes: 1}, + {name: "texte : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "beaucoup"}, wantNotes: 1}, + {name: "variable déjà posée : intouchée", + cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"}, + si: serveSysInfo{UserEnv: map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "32"}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, notes := opOffloadMinBatchEnv(c.cfg, c.si) + if got != c.want { + t.Fatalf("valeur = %q, attendu %q", got, c.want) + } + if len(notes) != c.wantNotes { + t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes) + } + }) + } +} + +// Les trois clés absentes, la ligne de commande et l'environnement ne bougent +// pas ; posées, --fit-target arrive avant EXTRA_ARGS et les variables vont +// dans env, nulle part ailleurs. +func TestBuildServeArgsExpertToggles(t *testing.T) { + const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf" + si := serveSysInfo{Help: helpFit, Model: model} + cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0"} + base, baseEnv, baseNotes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), bin, si) + if len(baseEnv) != 0 || len(baseNotes) != 0 { + t.Fatalf("sans les clés : env = %v, notes = %q", baseEnv, baseNotes) + } + on := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0", + "FIT_TARGET": "1024,3072", "OP_OFFLOAD_MIN_BATCH": "128", "CUDA_GRAPH_OPT": "on"} + args, env, notes := buildServeArgs(on, splitArgs(on["EXTRA_ARGS"]), bin, si) + n := len(base) + want := append(append(append([]string{}, base[:n-2]...), "--fit-target", "1024,3072"), base[n-2:]...) + if !reflect.DeepEqual(args, want) { + t.Fatalf("args\n got %q\nwant %q", args, want) + } + wantEnv := map[string]string{"GGML_CUDA_GRAPH_OPT": "1", "GGML_OP_OFFLOAD_MIN_BATCH": "128"} + if !reflect.DeepEqual(env, wantEnv) { + t.Fatalf("env = %v, want %v", env, wantEnv) + } + if len(notes) != 3 { + t.Fatalf("notes = %q", notes) + } +} diff --git a/internal/loki/backend_serve_queues.go b/internal/loki/backend_serve_queues.go index 96ad166..8d04a0a 100644 --- a/internal/loki/backend_serve_queues.go +++ b/internal/loki/backend_serve_queues.go @@ -114,19 +114,8 @@ func servedGPUCount(extra []string, si serveSysInfo) int { // drapeaux, chaque occurrence ajoute les siennes. Un --n-cpu-moe 0 n'annule // donc pas un LLAMA_ARG_N_CPU_MOE=30. func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]string) string { - if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") || - argEnv["LLAMA_ARG_OVERRIDE_TENSOR"] != "" || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) { - return "surcharge de tenseurs" - } - for _, n := range []struct{ env, short, long string }{ - {"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"}, - {"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"}, - } { - for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) { - if v = strings.TrimSpace(v); v != "" && v != "0" { - return "couches " + n.long + " sur CPU" - } - } + if r := tensorOverride(extra, argEnv); r != "" { + return r } if !kvOffloaded(extra, argEnv) { return "cache KV sur CPU" @@ -155,6 +144,28 @@ func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]st return "" } +// tensorOverride dit si des tenseurs sont placés à la main (-ot et ses formes +// déguisées --cpu-moe, --n-cpu-moe, --n-cpu-ffn), en drapeau ou en variable. +// Vide = aucun. Le pipeline entre cartes s'y arrête, et --fit aussi +// (« tensor_buft_overrides already set by user, abort »). +func tensorOverride(extra []string, argEnv map[string]string) string { + if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") || + argEnv["LLAMA_ARG_OVERRIDE_TENSOR"] != "" || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) { + return "surcharge de tenseurs" + } + for _, n := range []struct{ env, short, long string }{ + {"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"}, + {"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"}, + } { + for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) { + if v = strings.TrimSpace(v); v != "" && v != "0" { + return "couches " + n.long + " sur CPU" + } + } + } + return "" +} + // kvOffloaded : le cache KV reste-t-il sur GPU ? Le dernier -kvo/-nkvo de la // ligne de commande tranche ; sinon LLAMA_ARG_NO_KV_OFFLOAD, dont la SEULE // présence vaut « non » pour llama.cpp (quelle que soit sa valeur), puis diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 274e1ac..5c7f6e9 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -72,6 +72,12 @@ var configTemplate = []struct{ key, help string }{ {"UBATCH", "micro-batch (défaut 512)"}, {"THREADS", "threads CPU ; vide ou 0 = auto (cœurs physiques ; cœurs P seulement sur Intel hybride Linux)"}, {"THREADS_BATCH", "threads CPU du prefill et de la vérification spéculative (MTP) ; vide ou 0 = comme THREADS"}, + {"FIT_TARGET", "Mio laissés libres par carte par le placement auto (--fit-target), ex. 1024,3072 dans l'ordre des cartes ; " + + "vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"}, + {"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " + + "vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"}, + {"CUDA_GRAPH_OPT", "on = GGML_CUDA_GRAPH_OPT=1, branches Q/K/V en parallèle au décodage (expérimental, " + + "sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"}, {"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"}, {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"}, {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},