From 5954d751188b17feb08573ef32094b4eea8a0290 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 10:35:16 +0200 Subject: [PATCH] =?UTF-8?q?Placement=20:=20SPLIT=5FMODE=3Dtensor,=20parall?= =?UTF-8?q?=C3=A9lisme=20de=20tenseurs=20entre=20cartes,=20en=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En découpe par couches (défaut), chaque carte traverse ses couches à son tour. En mode tensor, chaque couche est coupée entre les cartes, qui additionnent ensuite leurs morceaux : décodage plus rapide d'après l'amont, prefill nettement plus lent. Sur deux GeForce en PCIe et une boucle d'agent qui relit beaucoup, rien n'est acquis : expérimental, off par défaut, et une longue liste de refus propres plutôt qu'un moteur qui meurt en boucle. Sans la clé, ligne et environnement identiques à l'octet près (test). - détection : la liste littérale {none,layer,row,tensor} dans l'aide du moteur — « tensor » seul y est partout (--tensor-split) ; absente = aucun compagnon posé, note au journal - GGML_CUDA_ALLREDUCE=internal sauf choix de l'environnement : NCCL compresse en BF16 les réductions du prefill (avec perte) ; nccl gardé s'il est posé, avec un avertissement ; note NCCL indisponible / P2P à poser soi-même - -ngl all, -ts au prorata de la VRAM totale (--list-devices, ordre du moteur, --device compris), -fa on : chacun seulement si EXTRA_ARGS ou une LLAMA_ARG_* ne l'a pas déjà ; CTX explicite exigé - refus : KV autre que f16/bf16/f32 (KV_TYPE*, -ctk/-ctv, LLAMA_ARG_*, jamais réécrit), --backend-sampling, -fa off, -sm à la main, poids ou KV sur CPU, NGL partiel, SPEC, SIDE_SLOT, une seule carte ou non CUDA, architectures exclues par llama.cpp et qwen4exp, preset externe - pas de --fit en mode tensor : estimation poids + cache de CTX jetons contre 90 % de la VRAM, refus au-delà, CTX jamais réduit - files CUDA, CUDA_GRAPH_OPT et FIT_TARGET voient le -sm tensor ; SLOT_PERSIST refusé avec la clé ; diagnostic du journal pour les échecs propres au mode tensor - README et configTemplate Co-Authored-By: Claude Opus 5.5 --- README.md | 23 ++ internal/loki/backend_serve.go | 32 +- internal/loki/backend_serve_persist.go | 2 + internal/loki/backend_serve_split.go | 359 ++++++++++++++++++++++ internal/loki/backend_serve_split_test.go | 214 +++++++++++++ internal/loki/sys_service.go | 6 + internal/loki/web_api.go | 4 + 7 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 internal/loki/backend_serve_split.go create mode 100644 internal/loki/backend_serve_split_test.go diff --git a/README.md b/README.md index 1cdce61..0566045 100644 --- a/README.md +++ b/README.md @@ -561,6 +561,29 @@ Ajoutées par ce fork : nature de requête (`kinds..draft_rate`) : à garder là où elle paie. Les drapeaux d'acceptation synthétique (`--spec-synth-*`, « benchmarking only »), que Loki ne pose jamais, déclenchent un avertissement au lancement. +- **Parallélisme de tenseurs** (clé `SPLIT_MODE=tensor`, **off** par défaut, + expérimental, moteur relancé) : au lieu de donner à chaque carte des couches + entières (`-sm layer`, défaut), chaque couche est coupée entre les cartes, + qui additionnent ensuite leurs morceaux. L'amont mesure un décodage plus + rapide mais un prefill nettement plus lent : sur deux GeForce en PCIe et une + boucle d'agent qui relit beaucoup, à mesurer par tour avant de l'adopter. + Loki n'active le mode que si l'aide du moteur liste `{none,layer,row,tensor}` + (le mot « tensor » seul est partout), et ajoute alors `-ngl all`, `-ts` au + prorata de la VRAM totale des cartes dans l'ordre du moteur (`--device` + compris), `-fa on` — chacun seulement s'il n'est pas déjà dans `EXTRA_ARGS` + ou une `LLAMA_ARG_*` — et `GGML_CUDA_ALLREDUCE=internal` si l'environnement + ne le fixe pas : le chemin NCCL compresse en BF16 les grosses réductions du + prefill, une perte de précision ; la réduction interne garde le type natif. + « NCCL not compiled in » au journal est alors sans effet ; `GGML_CUDA_P2P=1` + reste à poser soi-même si le pilote le permet. Pas de `--fit` en mode + tensor : Loki estime poids + cache de `CTX` jetons et refuse au-delà de 90 % + de la VRAM — jamais en réduisant `CTX`. Refusé aussi (découpe par couches, + note au journal) avec un cache KV autre que f16/bf16/f32 (jamais réécrit + d'office), des poids ou le cache KV sur CPU (`--n-cpu-moe`, `-ot`, `NGL` + partiel, `-nkvo`), `--backend-sampling`, `-fa off`, un `-sm` déjà réglé, + `SPEC`, `SIDE_SLOT`, `CTX` non chiffré, une seule carte ou une carte non + CUDA, une architecture que llama.cpp exclut (et `qwen4exp`), un preset + externe ; `SLOT_PERSIST` est refusé avec la clé. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 5beaa4b..b81fda8 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -346,6 +346,8 @@ func cmdServe(args []string) error { probeCacheRAM(cfg, extra, &si) probeCkptEnv(&si) probeSideSlotEnv(cfg, &si) + // SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre. + probeSplit(cfg, bin, &si) spec := specMode(cfg) if spec != "off" && spec != "" { probeSpec(cfg, &si) @@ -455,6 +457,10 @@ type serveSysInfo struct { DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles SpecAutoBlocked string // un essai automatique précédent a échoué : la raison + + // Cartes listées par le moteur (--list-devices), lues seulement pour + // SPLIT_MODE=tensor (voir backend_serve_split.go) ; vide = inconnues. + SplitDevs []splitDev } // cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par @@ -516,16 +522,30 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS notes = append(notes, loadNote) } + // Parallélisme de tenseurs (SPLIT_MODE=tensor, voir backend_serve_split.go) : + // sans la clé, splitOn est faux et rien de ce qui suit ne change. Accepté, il + // compte pour les réglages qui lisent le placement dans EXTRA_ARGS (files de + // lancement, graphes CUDA, --fit) comme un -sm tensor écrit à la main. + splitOn, splitA, splitNGLArgs, splitEnv, splitNotes := splitTensorArgs(cfg, extra, si) + place := extra + if splitOn { + place = append(append([]string{}, extra...), "-sm", "tensor") + } + for k, v := range splitEnv { + env[k] = v + } + notes = append(notes, splitNotes...) + // Files de lancement CUDA : une variable d'environnement, pas un drapeau — // la ligne de commande n'en dépend pas (voir launchQueuesEnv). - q, qNotes := launchQueuesEnv(cfg, extra, si) + q, qNotes := launchQueuesEnv(cfg, place, si) if q != "" { 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) + g, gNotes := graphOptEnv(cfg, place, si) if g != "" { env["GGML_CUDA_GRAPH_OPT"] = g } @@ -595,7 +615,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS // ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on // le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement // forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999). - if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") { + if splitOn { + args = append(args, splitNGLArgs...) // « -ngl all », ou rien si EXTRA_ARGS l'a déjà + } else if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") { ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help)) if note != "" { notes = append(notes, note) @@ -604,8 +626,8 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS } // 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...) + fitt, fitNotes := fitTargetArgs(cfg, place, si) + args = append(append(args, fitt...), splitA...) notes = append(notes, fitNotes...) if ktv != "" { args = append(args, "-ctk", ktv) diff --git a/internal/loki/backend_serve_persist.go b/internal/loki/backend_serve_persist.go index 751b01f..74714e1 100644 --- a/internal/loki/backend_serve_persist.go +++ b/internal/loki/backend_serve_persist.go @@ -84,6 +84,8 @@ func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok return false, "dossier des slots indisponible" case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "": return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon" + case splitModeKey(cfg) != "": + return false, "SPLIT_MODE : état réparti entre cartes, sauvegarde non validée" } if !slotSaveSafe(cfg, extra, si) { return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)" diff --git a/internal/loki/backend_serve_split.go b/internal/loki/backend_serve_split.go new file mode 100644 index 0000000..e455e15 --- /dev/null +++ b/internal/loki/backend_serve_split.go @@ -0,0 +1,359 @@ +package loki + +import ( + "context" + "fmt" + "math" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// Parallélisme de tenseurs (SPLIT_MODE=tensor, off par défaut) : expérimental. +// +// La découpe par couches (défaut de llama.cpp) donne à chaque carte des +// couches entières, traversées l'une après l'autre. En mode tensor, chaque +// couche est coupée entre les cartes, qui calculent en même temps puis +// additionnent leurs morceaux (all-reduce). Sur deux RTX 4090, l'amont mesure +// +21-30 % au décodage, mais -37 % au prefill — et la boucle d'agent de Loki +// relit beaucoup. Sur deux GeForce en PCIe sans P2P, la somme passe par +// l'hôte : un gain net par tour est loin d'être acquis. D'où l'opt-in, et une +// longue liste de refus propres plutôt qu'un moteur qui meurt en boucle. +// +// Fidélité : l'all-reduce change l'ordre des additions (bruit de virgule +// flottante), mais le chemin NCCL va plus loin — au-delà de 32 768 éléments il +// COMPRESSE les sommes partielles en BF16 (ggml-cuda.cu), soit à chaque couche +// de chaque prefill : une perte de précision du calcul, de la classe d'une +// quantification. Loki pose donc GGML_CUDA_ALLREDUCE=internal (réduction dans +// le type natif), sauf si l'utilisateur a choisi lui-même. Le cache KV doit +// rester f16, bf16 ou f32 : le mode tensor ne gère pas les caches quantifiés, +// et Loki ne réécrit jamais un type de cache d'office (ça change le budget +// VRAM). Pas de --fit en mode tensor (common/fit.cpp) : Loki estime la VRAM +// lui-même et refuse si le compte ne tient pas, sans jamais réduire CTX. + +// splitDev : une carte telle que la voit CE moteur (--list-devices), dans son +// ordre — celui que suivent -ts et --device. +type splitDev struct { + ID string // CUDA0, Vulkan1… + TotalMiB int64 +} + +// splitTensorDenyArch : les architectures que llama.cpp refuse en mode tensor +// (llm_arch_supports_sm_tensor) — il mourrait au chargement, et systemd +// relancerait en boucle. qwen4exp n'y est pas, mais Loki l'écarte : son preset +// place les experts sur CPU avec un cache q8_0, l'inverse de ce qu'exige le +// mode tensor. +var splitTensorDenyArch = map[string]bool{ + "grok": true, "mpt": true, "plamo2": true, "minicpm3": true, "gemma3n": true, "mamba": true, "mamba2": true, + "jamba": true, "falcon-h1": true, "olmo2": true, "olmoe": true, "deepseek2": true, "deepseek32": true, + "hy_v4": true, "dots3note": true, "glm-dsa": true, "bitnet": true, "t5": true, "nemotron_h": true, + "nemotron_h_moe": true, "granitehybrid": true, "minimax-01": true, "minimax-m2": true, "minimax-m3": true, + "mistral4": true, "kimi-linear": true, "bailingmoe3": true, "kimi-k3": true, "glm5-next": true, + "qwen3tts": true, "qwen4exp": true, +} + +// splitArgEnv : les variables qui décident de ce que SPLIT_MODE règlerait. +var splitArgEnv = []string{"LLAMA_ARG_FLASH_ATTN", "LLAMA_ARG_BACKEND_SAMPLING", "LLAMA_ARG_TENSOR_SPLIT"} + +// splitModeKey lit SPLIT_MODE : "" (clé absente, off ou layer — le défaut du +// moteur, rien à poser), "tensor", ou "?" si illisible. +func splitModeKey(cfg map[string]string) string { + switch v := strings.ToLower(strings.TrimSpace(cfg["SPLIT_MODE"])); v { + case "", "off", "layer", "non", "no", "0": + return "" + case "tensor": + return v + } + return "?" +} + +// helpSupportsSplitTensor : la liste LITTÉRALE des valeurs de -sm. Le mot +// « tensor » seul est partout dans l'aide (--tensor-split, --override-tensor) : +// s'y fier ferait passer -sm tensor à un moteur qui le refuse au démarrage. +func helpSupportsSplitTensor(help string) bool { + return strings.Contains(help, "{none,layer,row,tensor}") +} + +// probeSplit : seulement si SPLIT_MODE=tensor et que le moteur le propose — +// sans la clé, rien n'est lu ni lancé. --list-devices tourne dans +// l'environnement du vrai lancement (bibliothèques, CUDA_VISIBLE_DEVICES et +// PCI_BUS_ID déjà posés par cmdServe) : même ordre de cartes que le moteur. +func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) { + if splitModeKey(cfg) != "tensor" || !helpSupportsSplitTensor(si.Help) { + return + } + if si.ArgEnv == nil { + si.ArgEnv = map[string]string{} + } + for _, k := range splitArgEnv { + if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" { + si.ArgEnv[k] = v + } + } + if si.UserEnv == nil { + si.UserEnv = map[string]string{} + } + if v := os.Getenv("GGML_CUDA_ALLREDUCE"); v != "" { + si.UserEnv["GGML_CUDA_ALLREDUCE"] = v + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + out, err := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")).CombinedOutput() + if err != nil { + return // sortie tronquée (carte pleine, moteur planté) : on ne s'y fie pas + } + for _, d := range parseListDevices(string(out)) { + id, _ := d["id"].(string) + total, _ := d["total_mib"].(int) + si.SplitDevs = append(si.SplitDevs, splitDev{ID: id, TotalMiB: int64(total)}) + } +} + +// splitPlan décide de SPLIT_MODE=tensor pour ce lancement. Fonction pure. +// +// on : accepté ; la ligne gagne -sm tensor et ses compagnons +// why : pourquoi refusé (vide = clé absente) +// devs : les cartes du découpage, dans l'ordre du moteur +// needMiB : la VRAM estimée (poids + cache de CTX jetons) +func splitPlan(cfg map[string]string, extra []string, si serveSysInfo) (on bool, why string, devs []splitDev, needMiB int64) { + switch splitModeKey(cfg) { + case "": + return false, "", nil, 0 + case "?": + return false, "SPLIT_MODE=" + strings.TrimSpace(cfg["SPLIT_MODE"]) + " illisible (tensor ou off)", nil, 0 + } + refuse := func(why string) (bool, string, []splitDev, int64) { return false, why, nil, 0 } + if isExternalConfig(cfg) { + return refuse("preset externe") + } + if !helpSupportsSplitTensor(si.Help) { + return refuse("ce moteur ne propose pas -sm tensor") + } + if sm := flagValue(extra, "-sm", "--split-mode"); sm != "" { + return refuse("-sm " + sm + " déjà dans EXTRA_ARGS") + } + if si.ArgEnv["LLAMA_ARG_SPLIT_MODE"] != "" { + return refuse("LLAMA_ARG_SPLIT_MODE déjà posée") + } + fa := flagValue(extra, "-fa", "--flash-attn") + if fa == "" { + fa = si.ArgEnv["LLAMA_ARG_FLASH_ATTN"] + } + switch strings.ToLower(strings.TrimSpace(fa)) { + case "off", "disabled", "false", "0": + return refuse("Flash Attention coupée (-fa " + fa + ") : le mode tensor l'exige") + } + if hasAnyFlag(extra, "-bs", "--backend-sampling") || envTruthy(si.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"]) { + return refuse("échantillonnage sur GPU (--backend-sampling) non pris en charge en mode tensor") + } + k, v, _ := effectiveKVTypes(cfg, extra, si.ArgEnv) + if !splitKVOK(k) || !splitKVOK(v) { + return refuse(fmt.Sprintf("cache KV %s/%s : le mode tensor n'accepte que f16, bf16 ou f32 "+ + "(jamais réécrit d'office : la VRAM changerait)", kvName(k), kvName(v))) + } + if r := tensorOverride(extra, si.ArgEnv); r != "" { + return refuse(r + " : le mode tensor met tout sur les cartes") + } + if !kvOffloaded(extra, si.ArgEnv) { + return refuse("cache KV sur CPU") + } + switch { + case si.GGUF == nil || si.ModelBytes <= 0: + return refuse("métadonnées ou taille du modèle illisibles") + case splitTensorDenyArch[strings.ToLower(si.GGUF.Arch)]: + return refuse("architecture " + si.GGUF.Arch + " exclue du mode tensor") + } + if _, why := splitNGL(cfg, extra, si.GGUF.BlockCount); why != "" { + return refuse(why) + } + ctxS, ok := ctxFixed(cfg, extra) + if !ok { + return refuse("contexte « " + ctxS + " » non chiffré : pas de --fit en mode tensor, donne un CTX explicite") + } + if sideSlotOn(cfg) { + return refuse("SIDE_SLOT : cache doublé, sans --fit pour rattraper") + } + if specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) { + return refuse("décodage spéculatif : brouillon et vérification par lots non validés en mode tensor") + } + devs, why = splitDevices(extra, si) + if why != "" { + return refuse(why) + } + // VRAM : poids + cache de CTX jetons (et état récurrent) contre 90 % des + // cartes, la marge de SIDE_SLOT. Les tampons de calcul n'y sont pas : c'est + // un minimum, pas une promesse — le journal dira le reste. + ctx, _ := strconv.Atoi(ctxS) + perTok, rec := ggufStateBytes(*si.GGUF, k, v) + if perTok <= 0 { + return refuse("taille du cache KV inconnue pour ce modèle") + } + var total int64 + for _, d := range devs { + total += d.TotalMiB + } + mm := int64(0) + if si.MMProj != "" { + if st, err := os.Stat(si.MMProj); err == nil { + mm = st.Size() + } + } + need := float64(si.ModelBytes+mm) + perTok*float64(ctx) + float64(rec) + needMiB = int64(math.Ceil(need / (1 << 20))) + if need > 0.9*float64(total)*(1<<20) { + return refuse(fmt.Sprintf("VRAM insuffisante sans --fit : ~%d Mio (poids + cache de %d jetons) pour %d Mio "+ + "sur %d cartes — CTX n'est jamais réduit d'office", needMiB, ctx, total, len(devs))) + } + return true, "", devs, needMiB +} + +func splitKVOK(t string) bool { + switch strings.ToLower(strings.TrimSpace(t)) { + case "", "f16", "bf16", "f32": + return true + } + return false +} + +func kvName(t string) string { + if t == "" { + return "f16" + } + return t +} + +// splitNGL : les couches GPU en mode tensor, où tout doit être sur les cartes. +// Un -ngl d'EXTRA_ARGS reste celui de l'utilisateur (rien à ajouter) s'il dit +// « tout » ; sinon NGL est traduit en « -ngl all » (999, auto et la clé absente +// visent déjà le tout-GPU, mais auto ne fait rien sans --fit). Une valeur qui +// laisse des couches au CPU est refusée. +func splitNGL(cfg map[string]string, extra []string, blocks int) (args []string, why string) { + all := func(v string) bool { + v = strings.ToLower(strings.TrimSpace(v)) + if v == "all" || v == "999" { + return true + } + n, err := strconv.Atoi(v) + return err == nil && blocks > 0 && n >= blocks+1 + } + if v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"); v != "" { + if all(v) { + return nil, "" + } + return nil, "-ngl " + v + " dans EXTRA_ARGS : le mode tensor veut toutes les couches sur les cartes (all)" + } + switch v := strings.TrimSpace(cfg["NGL"]); { + case v == "" || strings.EqualFold(v, "auto") || all(v): + return []string{"-ngl", "all"}, "" + default: + return nil, "NGL=" + v + " : le mode tensor veut toutes les couches sur les cartes (all)" + } +} + +// splitDevices : les cartes du découpage. --device (EXTRA_ARGS ou variable) +// choisit lesquelles, dans son ordre ; sinon toutes celles que liste le +// moteur. Toutes CUDA (l'all-reduce n'existe qu'entre cartes CUDA), deux au +// moins. +func splitDevices(extra []string, si serveSysInfo) ([]splitDev, string) { + if len(si.SplitDevs) == 0 { + return nil, "cartes inconnues (--list-devices n'a rien répondu)" + } + devs := si.SplitDevs + sel := flagValue(extra, "-dev", "--device") + if sel == "" { + sel = si.ArgEnv["LLAMA_ARG_DEVICE"] + } + if sel = strings.TrimSpace(sel); sel != "" { + devs = nil + for _, name := range strings.Split(sel, ",") { + name = strings.TrimSpace(name) + found := false + for _, d := range si.SplitDevs { + if strings.EqualFold(d.ID, name) { + devs, found = append(devs, d), true + break + } + } + if !found { + return nil, "--device " + name + " inconnu de ce moteur" + } + } + } + for _, d := range devs { + if !strings.HasPrefix(strings.ToUpper(d.ID), "CUDA") { + return nil, d.ID + " n'est pas une carte CUDA" + } + } + if len(devs) < 2 { + return nil, "une seule carte" + } + return devs, "" +} + +// splitTS : la répartition par défaut, au prorata de la VRAM TOTALE de chaque +// carte (la libre varie avec ce qui tourne encore au lancement), en pour cent. +func splitTS(devs []splitDev) string { + var total int64 + for _, d := range devs { + total += d.TotalMiB + } + if total <= 0 { + return "" + } + parts := make([]string, len(devs)) + for i, d := range devs { + parts[i] = strconv.FormatInt(int64(math.Round(100*float64(d.TotalMiB)/float64(total))), 10) + } + return strings.Join(parts, ",") +} + +// splitTensorArgs : ce que SPLIT_MODE=tensor ajoute à la ligne, à la place du -ngl +// habituel (nglOut), l'environnement et les notes. on=false : rien, et la +// ligne reste celle d'avant à l'octet près. +func splitTensorArgs(cfg map[string]string, extra []string, si serveSysInfo) (on bool, args, ngl []string, env map[string]string, notes []string) { + ok, why, devs, needMiB := splitPlan(cfg, extra, si) + if !ok { + if why != "" { + notes = append(notes, "SPLIT_MODE=tensor refusé ("+why+") : découpe par couches, comme sans la clé") + } + return false, nil, nil, nil, notes + } + ngl, _ = splitNGL(cfg, extra, si.GGUF.BlockCount) + args = []string{"-sm", "tensor"} + ts := "" + if !hasAnyFlag(extra, "-ts", "--tensor-split") && si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] == "" { + if ts = splitTS(devs); ts != "" { + args = append(args, "-ts", ts) + } + } + // llama.cpp active de lui-même Flash Attention en mode tensor ; l'écrire + // le rend lisible dans le journal. Jamais en double d'un -fa de l'utilisateur. + if !hasAnyFlag(extra, "-fa", "--flash-attn") && si.ArgEnv["LLAMA_ARG_FLASH_ATTN"] == "" { + args = append(args, "-fa", "on") + } + env = map[string]string{} + note := fmt.Sprintf("SPLIT_MODE=tensor (expérimental) : -sm tensor sur %d cartes", len(devs)) + if ts != "" { + note += ", -ts " + ts + " (VRAM totale)" + } + note += fmt.Sprintf(", ~%d Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour", needMiB) + switch ar := strings.ToLower(strings.TrimSpace(si.UserEnv["GGML_CUDA_ALLREDUCE"])); ar { + case "": + env["GGML_CUDA_ALLREDUCE"] = "internal" + note += " ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). " + + "« NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les " + + "sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet" + case "nccl": + note += " ; avertissement : GGML_CUDA_ALLREDUCE=nccl (environnement) — NCCL compresse en BF16 les grosses " + + "réductions du prefill : calcul avec perte. « NCCL not compiled in » au journal = repli sur la réduction interne" + default: + note += " ; GGML_CUDA_ALLREDUCE=" + ar + " (environnement) gardé" + } + if si.GGUF.Hybrid { + note += " ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont" + } + notes = append(notes, note) + return true, args, ngl, env, notes +} diff --git a/internal/loki/backend_serve_split_test.go b/internal/loki/backend_serve_split_test.go new file mode 100644 index 0000000..c020c20 --- /dev/null +++ b/internal/loki/backend_serve_split_test.go @@ -0,0 +1,214 @@ +package loki + +import ( + "reflect" + "slices" + "strings" + "testing" +) + +const helpTensor = helpRecent + `-sm, --split-mode {none,layer,row,tensor} + how to split the model across multiple GPUs +-ts, --tensor-split N0,N1,N2,... fraction of the model to offload to each GPU +` + +// tensorReady : un hybride 64 couches de 16 Gio sur 16 + 12 Gio de VRAM, sur +// un moteur qui propose -sm tensor. Le cas où SPLIT_MODE=tensor est accepté. +func tensorReady() serveSysInfo { + return serveSysInfo{ + Help: helpTensor, + Model: "/models/Qwen3.6-27B.gguf", + GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 64, KeyLen: 256, ValLen: 256, HeadCountKV: 4, + Hybrid: true, FullAttnInterval: 4}, + ModelBytes: 16 << 30, + ArgEnv: map[string]string{}, + UserEnv: map[string]string{}, + SplitDevs: []splitDev{{ID: "CUDA0", TotalMiB: 16311}, {ID: "CUDA1", TotalMiB: 12288}}, + } +} + +func TestSplitTensorPlan(t *testing.T) { + tensor := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536"} + with := func(kv ...string) map[string]string { + m := map[string]string{} + for k, v := range tensor { + m[k] = v + } + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i]] = kv[i+1] + } + return m + } + cases := []struct { + name string + cfg map[string]string + extra []string + si func(*serveSysInfo) + on bool + why string // fragment attendu de la raison du refus + }{ + {name: "clé absente : rien, en silence", cfg: map[string]string{}}, + {name: "SPLIT_MODE=layer : le défaut, rien", cfg: map[string]string{"SPLIT_MODE": "layer"}}, + {name: "accepté", cfg: tensor, on: true}, + {name: "illisible", cfg: with("SPLIT_MODE", "rows"), why: "illisible"}, + {name: "aide qui ne cite que --tensor-split : refusé", + cfg: tensor, why: "-sm tensor", si: func(s *serveSysInfo) { s.Help = helpRecent + "-ts, --tensor-split N0,N1\n" }}, + {name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"}, + {name: "-sm dans EXTRA_ARGS : l'utilisateur garde la main", cfg: tensor, extra: []string{"-sm", "layer"}, why: "-sm layer"}, + {name: "LLAMA_ARG_SPLIT_MODE", cfg: tensor, why: "LLAMA_ARG_SPLIT_MODE", + si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPLIT_MODE"] = "row" }}, + {name: "-fa off : refusé", cfg: tensor, extra: []string{"-fa", "off"}, why: "Flash Attention"}, + {name: "-fa on : accepté", cfg: tensor, extra: []string{"-fa", "on"}, on: true}, + {name: "--backend-sampling : refusé", cfg: tensor, extra: []string{"--backend-sampling"}, why: "backend-sampling"}, + {name: "LLAMA_ARG_BACKEND_SAMPLING : refusé", cfg: tensor, why: "backend-sampling", + si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"] = "1" }}, + {name: "KV_TYPE=q8_0 : refusé, jamais réécrit", cfg: with("KV_TYPE", "q8_0"), why: "q8_0"}, + {name: "-ctk q8_0 dans EXTRA_ARGS (preset MoE) : refusé", cfg: tensor, + extra: []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0"}, why: "q8_0"}, + {name: "KV_TYPE_V=q4_0 : refusé", cfg: with("KV_TYPE_V", "q4_0"), why: "q4_0"}, + {name: "KV bf16 : accepté", cfg: with("KV_TYPE", "bf16"), on: true}, + {name: "--n-cpu-moe : refusé", cfg: tensor, extra: []string{"--n-cpu-moe", "40"}, why: "n-cpu-moe"}, + {name: "-ot : refusé", cfg: tensor, extra: []string{"-ot", "exps=CPU"}, why: "surcharge"}, + {name: "cache KV sur CPU : refusé", cfg: tensor, extra: []string{"-nkvo"}, why: "KV sur CPU"}, + {name: "NGL=40 : refusé", cfg: with("NGL", "40"), why: "NGL=40"}, + {name: "NGL=999 : accepté", cfg: with("NGL", "999"), on: true}, + {name: "-ngl 99 dans EXTRA_ARGS (≥ toutes les couches) : accepté", cfg: tensor, extra: []string{"-ngl", "99"}, on: true}, + {name: "-ngl 30 dans EXTRA_ARGS : refusé", cfg: tensor, extra: []string{"-ngl", "30"}, why: "-ngl 30"}, + {name: "CTX=0 : refusé", cfg: with("CTX", "0"), why: "non chiffré"}, + {name: "VRAM insuffisante : refusé, CTX jamais réduit", cfg: with("CTX", "262144"), why: "VRAM insuffisante"}, + {name: "qwen4exp : refusé", cfg: tensor, why: "qwen4exp", si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }}, + {name: "mamba (refusé par llama.cpp) : refusé proprement", cfg: tensor, why: "mamba", + si: func(s *serveSysInfo) { s.GGUF.Arch = "mamba" }}, + {name: "GGUF illisible", cfg: tensor, why: "illisibles", si: func(s *serveSysInfo) { s.GGUF = nil }}, + {name: "SPEC=mtp : refusé", cfg: with("SPEC", "mtp"), why: "spéculatif"}, + {name: "SIDE_SLOT=on : refusé", cfg: with("SIDE_SLOT", "on"), why: "SIDE_SLOT"}, + {name: "une seule carte", cfg: tensor, why: "une seule carte", + si: func(s *serveSysInfo) { s.SplitDevs = s.SplitDevs[:1] }}, + {name: "cartes inconnues", cfg: tensor, why: "inconnues", si: func(s *serveSysInfo) { s.SplitDevs = nil }}, + {name: "moteur Vulkan : refusé", cfg: tensor, why: "CUDA", + si: func(s *serveSysInfo) { s.SplitDevs = []splitDev{{"Vulkan0", 16311}, {"Vulkan1", 12288}} }}, + {name: "--device d'une seule carte : refusé", cfg: tensor, extra: []string{"--device", "CUDA0"}, why: "une seule carte"}, + {name: "--device inconnu : refusé", cfg: tensor, extra: []string{"-dev", "CUDA0,CUDA7"}, why: "CUDA7"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + si := tensorReady() + if c.si != nil { + c.si(&si) + } + on, why, _, _ := splitPlan(c.cfg, c.extra, si) + if on != c.on { + t.Fatalf("on = %v, attendu %v (%q)", on, c.on, why) + } + if !strings.Contains(why, c.why) || (c.why == "" && why != "") { + t.Errorf("raison %q, attendu %q", why, c.why) + } + }) + } +} + +func TestSplitTensorArgs(t *testing.T) { + const bin = "/opt/llama/llama-server" + cfg := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536", "NGL": "999"} + + // Clé absente : la ligne et l'environnement d'avant, à l'octet près, même + // sur un moteur et une machine où le mode tensor serait possible. + plain := map[string]string{"CTX": "65536", "NGL": "999"} + got, env, _ := buildServeArgs(plain, nil, bin, tensorReady()) + si := tensorReady() + si.Help, si.SplitDevs = helpRecent, nil + want, wantEnv, _ := buildServeArgs(plain, nil, bin, si) + if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(env, wantEnv) { + t.Fatalf("sans SPLIT_MODE : %q %v, attendu %q %v", got, env, want, wantEnv) + } + + got, env, notes := buildServeArgs(cfg, nil, bin, tensorReady()) + tail := []string{"-ngl", "all", "-sm", "tensor", "-ts", "57,43", "-fa", "on"} + if i := slices.Index(got, "-ngl"); i < 0 || !reflect.DeepEqual(got[i:i+len(tail)], tail) { + t.Fatalf("args = %q, attendu …%q…", got, tail) + } + if env["GGML_CUDA_ALLREDUCE"] != "internal" { + t.Errorf("GGML_CUDA_ALLREDUCE = %q, attendu internal (NCCL compresse en BF16)", env["GGML_CUDA_ALLREDUCE"]) + } + if len(notes) == 0 || !strings.Contains(strings.Join(notes, "\n"), "NCCL") { + t.Errorf("la note doit parler de NCCL : %q", notes) + } + + // Les choix de l'utilisateur gagnent et ne sont jamais doublés. + extra := []string{"-ts", "60,40", "-fa", "on", "-ngl", "all"} + got, _, _ = buildServeArgs(cfg, extra, bin, tensorReady()) + for _, f := range []string{"-ts", "-fa", "-ngl", "-sm"} { + if n := countFlag(got, f); n != 1 { + t.Errorf("%s présent %d fois : %q", f, n, got) + } + } + + // --device dans l'ordre inverse : la répartition suit. + got, _, _ = buildServeArgs(cfg, []string{"--device", "CUDA1,CUDA0"}, bin, tensorReady()) + if argValue(got, "-ts") != "43,57" { + t.Errorf("-ts = %q, attendu 43,57", argValue(got, "-ts")) + } + + // GGML_CUDA_ALLREDUCE déjà posé : gardé, et NCCL signalé comme avec perte. + si = tensorReady() + si.UserEnv["GGML_CUDA_ALLREDUCE"] = "nccl" + _, env, notes = buildServeArgs(cfg, nil, bin, si) + if _, set := env["GGML_CUDA_ALLREDUCE"]; set { + t.Errorf("choix de l'utilisateur écrasé : %v", env) + } + if !strings.Contains(strings.Join(notes, "\n"), "BF16") { + t.Errorf("NCCL doit être signalé : %q", notes) + } + + // Les réglages qui lisent le placement voient le mode tensor : pas de + // graphes Q/K/V en parallèle, pas de --fit-target, pas de file CUDA. + withOpts := map[string]string{"CUDA_GRAPH_OPT": "on", "FIT_TARGET": "1024"} + for k, v := range cfg { + withOpts[k] = v + } + got, env, _ = buildServeArgs(withOpts, nil, bin, tensorReady()) + if env["GGML_CUDA_GRAPH_OPT"] != "" || slices.Contains(got, "--fit-target") { + t.Errorf("mode tensor : ni GGML_CUDA_GRAPH_OPT ni --fit-target, %q %v", got, env) + } + + // Moteur sans -sm tensor : aucun compagnon (-ngl all, -ts, -fa), une note. + si = tensorReady() + si.Help = helpRecent + got, env, notes = buildServeArgs(cfg, nil, bin, si) + if slices.Contains(got, "-sm") || slices.Contains(got, "-ts") || slices.Contains(got, "-fa") || + argValue(got, "-ngl") != "auto" || env["GGML_CUDA_ALLREDUCE"] != "" { + t.Errorf("refusé : ligne d'avant attendue, %q %v", got, env) + } + if !strings.Contains(strings.Join(notes, "\n"), "SPLIT_MODE=tensor refusé") { + t.Errorf("le refus doit se dire : %q", notes) + } +} + +func countFlag(args []string, f string) int { + n := 0 + for _, a := range args { + if a == f { + n++ + } + } + return n +} + +func TestSplitTensorSlotPersist(t *testing.T) { + si := tensorReady() + si.Help += "--slot-save-path PATH\n" + si.SlotDir = "/data/slots" + cfg := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1", "SPLIT_MODE": "tensor"} + if ok, why := slotPersistPlan(cfg, nil, si); ok || !strings.Contains(why, "SPLIT_MODE") { + t.Errorf("SLOT_PERSIST avec SPLIT_MODE : refusé attendu, got %v %q", ok, why) + } +} + +func TestHelpSupportsSplitTensor(t *testing.T) { + if helpSupportsSplitTensor(helpRecent + "-ts, --tensor-split N0,N1 fraction\n--override-tensor\n") { + t.Error("« tensor » ailleurs dans l'aide ne suffit pas") + } + if !helpSupportsSplitTensor(helpTensor) { + t.Error("liste littérale de -sm : oui") + } +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 6db5cd5..d02e168 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -76,6 +76,12 @@ var configTemplate = []struct{ key, help string }{ "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"}, + {"SPLIT_MODE", "tensor = parallélisme de tenseurs entre cartes CUDA (-sm tensor, expérimental) : décodage plus rapide, " + + "prefill plus lent, à mesurer par tour. Loki pose -ngl all, -ts au prorata de la VRAM, -fa on (sauf réglés à la main) " + + "et GGML_CUDA_ALLREDUCE=internal (NCCL compresse en BF16, avec perte). Refusé sans la liste {none,layer,row,tensor} " + + "dans l'aide du moteur, avec un cache KV autre que f16/bf16/f32, des poids ou le KV sur CPU, --backend-sampling, " + + "-fa off, -sm à la main, SPEC, SIDE_SLOT, CTX non chiffré, une seule carte ou une VRAM estimée insuffisante. " + + "Vide/off (défaut) = découpe par couches"}, {"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 ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " + diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 51c63f7..001e413 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -120,6 +120,10 @@ func modelLoadError() string { strings.Contains(low, "failed to initialize speculative decoding context"): // Avant l'échec générique : c'est le brouillon qui manque, pas le modèle. reason = "le décodage spéculatif n'a pas pu démarrer (tête MTP ou modèle brouillon) — SPEC=off dans le preset" + case strings.Contains(low, "split_mode_tensor requires flash_attn"), + strings.Contains(low, "split_mode_tensor not implemented"): + // Les autres lignes qui citent SPLIT_MODE_TENSOR ne sont que des avis. + reason = "le mode tensor (-sm tensor) n'a pas pu démarrer — SPLIT_MODE=off dans le preset" case strings.Contains(l, "has offset") && strings.Contains(l, "expected"): reason = "format de quantification non reconnu par ce moteur" case strings.Contains(low, "unknown model architecture"),