Files
Loki/internal/loki/backend_serve_split.go
T
MichaelandClaude Opus 5.5 f0fef91773 Moteur : LOAD_GUARD ne refuse plus sur la seule VRAM NVIDIA (Vulkan, cartes mixtes)
La borne basse des poids en RAM se calculait avec la VRAM de nvidia-smi. Un
moteur Vulkan sur une machine NVIDIA + AMD, sans --device, place aussi des
poids sur la carte AMD : la borne était fausse et le lancement refusé à tort.
Un moteur ROCm à côté d'une carte NVIDIA n'utilise pas du tout celle-ci.

- Le refus se décide désormais sur les cartes que le moteur liste lui-même
  (--list-devices, dans l'environnement du lancement, sélection GPU
  comprise), toutes additionnées : rien au-delà ne peut recevoir de poids, la
  borne est sûre. Une carte listée sans servir (iGPU) ne fait que rendre le
  refus plus rare.
- Lu seulement quand un refus se profile sur la VRAM NVIDIA : aucun lancement
  de plus dans le cas ordinaire. Les cartes de SPLIT_MODE sont reprises si
  elles sont déjà lues.
- Liste illisible, vide ou carte à 0 Mio (déjà pleine) : avertissement,
  jamais de refus. Même chose dans l'aperçu de l'éditeur, qui reprend la
  dernière liste complète du moteur (devices.json) sans le lancer.
- Le reste ne bouge pas : VRAM inconnue, --device, --rpc, mémoire unifiée.
- Tests : faux refus Vulkan évité, nvidia-smi seul n'est qu'un avis, lecture
  déclenchée seulement avec un refus en vue, somme des cartes (JSON relu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:33:46 +02:00

382 lines
15 KiB
Go

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
}
si.SplitDevs = listEngineDevices(bin)
}
// listEngineDevices : les cartes que CE moteur voit (--list-devices), dans
// l'environnement du processus — celui du vrai lancement quand cmdServe
// appelle. nil si le moteur échoue : sortie tronquée (carte pleine, moteur
// planté), on ne s'y fie pas.
func listEngineDevices(bin string) []splitDev {
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 nil
}
return splitDevsFrom(parseListDevices(string(out)))
}
// splitDevsFrom : les cartes de parseListDevices (ou de devices.json, relu en
// JSON : les nombres y sont des float64).
func splitDevsFrom(list []map[string]any) []splitDev {
var devs []splitDev
for _, d := range list {
id, _ := d["id"].(string)
var total int64
switch v := d["total_mib"].(type) {
case int:
total = int64(v)
case float64:
total = int64(v)
}
devs = append(devs, splitDev{ID: id, TotalMiB: total})
}
return devs
}
// 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
}