Moteur : trois réglages d'expert sur demande — marge VRAM par carte (FIT_TARGET), seuil d'offload des experts, branches Q/K/V parallèles

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-03 22:53:44 +02:00
1 parent 2de20e0ab2
commit 9aa426a389
5 files changed
+519 -14

No files matched your search

+19 -1
View File
@@ -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)
}
+263
View File
@@ -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 ""
}
+207
View File
@@ -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)
}
}
+24 -13
View File
@@ -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
+6
View File
@@ -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é"},