diff --git a/README.md b/README.md index 5619648..afa98a6 100644 --- a/README.md +++ b/README.md @@ -651,8 +651,11 @@ Ajoutées par ce fork : d'une version précédente (ou launchd) reçoit une sortie sans erreur pour ne pas boucler — `sudo systemctl edit loki-engine` (section `[Service]`, `RestartPreventExitStatus=78`) met l'unité à jour. - VRAM inconnue (Mac Intel, AMD, Vulkan, `--device`) ou serveurs `--rpc` : - avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ; + La VRAM qui fonde un refus est celle des cartes que le moteur liste + lui-même (`--list-devices`, lu seulement quand un refus se profile sur la + VRAM NVIDIA) : un moteur Vulkan sur cartes mixtes NVIDIA + AMD compte les + deux. Liste illisible, VRAM inconnue (Mac Intel, AMD seule, `--device`) ou + serveurs `--rpc` : avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ; `LLAMA_ARG_NO_MMAP`/`LLAMA_ARG_MLOCK` ne comptent que sur un moteur ancien (un moteur à `--load-mode` les ignore). Rien ne change dans la ligne de commande. diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index b7c11b0..4774edf 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -361,6 +361,10 @@ func cmdServe(args []string) error { probeSideSlotEnv(cfg, &si) // SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre. probeSplit(cfg, bin, &si) + // LOAD_GUARD : un refus envisagé sur la VRAM NVIDIA se décide sur les + // cartes du moteur (Vulkan sur cartes mixtes, ROCm…). Sinon rien n'est lu. + normExtra, _ := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help)) + probeLoadDevices(cfg, normExtra, bin, &si) spec := specMode(cfg) if spec != "off" && spec != "" { probeSpec(cfg, &si) @@ -489,7 +493,11 @@ type serveSysInfo struct { RAMAvailMiB int64 // RAM libre au lancement VRAMMiB int64 // VRAM NVIDIA des cartes visibles UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk) - SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau + // VRAM des cartes listées par le moteur lui-même (--list-devices), lue + // seulement quand LOAD_GUARD envisage un refus (probeLoadDevices) ; 0 = + // non lue : un avis au plus, jamais de refus sur la seule VRAM NVIDIA. + EngineVRAMMiB int64 + SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau // Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un // modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau diff --git a/internal/loki/backend_serve_moe.go b/internal/loki/backend_serve_moe.go index a9814e2..fe8a1e9 100644 --- a/internal/loki/backend_serve_moe.go +++ b/internal/loki/backend_serve_moe.go @@ -325,20 +325,32 @@ func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, // Serveurs RPC : une part des poids part sur d'autres machines, la VRAM // locale ne borne plus rien — inconnu, donc un avis au plus, jamais un refus. remote := hasAnyFlag(args, "--rpc") || strings.TrimSpace(si.ArgEnv["LLAMA_ARG_RPC"]) != "" - low, est, upTo := int64(0), modelMiB, true + low, est, upTo, unsure := int64(0), modelMiB, true, false switch { case remote: case si.UnifiedMem: low, est, upTo = modelMiB, modelMiB, false - case si.VRAMMiB > 0: - low = max(modelMiB-si.VRAMMiB, 0) + case si.EngineVRAMMiB > 0: + // Les cartes que le moteur liste lui-même : rien au-delà ne peut + // recevoir de poids, la borne basse est sûre. + low = max(modelMiB-si.EngineVRAMMiB, 0) est, upTo = low, false + case si.VRAMMiB > 0: + // nvidia-smi seulement : un moteur Vulkan sur cartes mixtes NVIDIA + + // AMD en utilise davantage, un moteur ROCm d'autres. Une estimation, + // jamais une borne : un avis au plus (voir probeLoadDevices). + est, upTo, unsure = max(modelMiB-si.VRAMMiB, 0), false, true } ram := si.RAMMiB what := "environ " if upTo { what = "jusqu'à " } + if unsure && est*10 > ram*9 { + return fmt.Sprintf("%s : environ %s de poids en RAM (modèle %s, VRAM NVIDIA %s) pour %s — échec probable ; "+ + "refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; "+ + "--load-mode mmap est plus sûr", mode, gibText(est), gibText(modelMiB), gibText(si.VRAMMiB), gibText(ram)), "" + } if low*10 > ram*9 { msg := fmt.Sprintf("%s : au moins %s de poids restent en RAM (modèle %s) pour %s de RAM, limite du conteneur "+ "comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). "+ @@ -355,6 +367,50 @@ func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, return "", "" } +// loadGuardNeedsDevices : un refus se profile sur la seule VRAM NVIDIA +// (nvidia-smi) ? Il ne sera prononcé que sur les cartes du moteur lui-même — +// à lire d'abord (probeLoadDevices). Fonction pure. +func loadGuardNeedsDevices(cfg map[string]string, args []string, si serveSysInfo) bool { + if si.EngineVRAMMiB > 0 || si.VRAMMiB <= 0 { + return false + } + si.EngineVRAMMiB = si.VRAMMiB + _, refuse := loadModeRisk(cfg, args, si) + return refuse != "" +} + +// engineDevsVRAM : la VRAM des cartes listées par le moteur, toutes +// additionnées — sans --device, llama.cpp ne place rien ailleurs, et une carte +// qu'il listerait sans s'en servir (iGPU) ne fait que rendre le refus plus +// rare. 0 (inconnu) si la liste est vide ou si une carte annonce 0 Mio (déjà +// pleine : lecture transitoire, voir handleBackendDevices). +func engineDevsVRAM(devs []splitDev) int64 { + var total int64 + for _, d := range devs { + if d.TotalMiB <= 0 { + return 0 + } + total += d.TotalMiB + } + return total +} + +// probeLoadDevices : seulement quand loadGuardNeedsDevices — un mode de +// chargement résident qu'on s'apprête à refuser. Les cartes de SPLIT_MODE si +// elles sont déjà lues, sinon --list-devices dans l'environnement du +// lancement (sélection GPU comprise). Échec de lecture : EngineVRAMMiB reste +// à 0, loadModeRisk avertit sans refuser. args : EXTRA_ARGS normalisé. +func probeLoadDevices(cfg map[string]string, args []string, bin string, si *serveSysInfo) { + if !loadGuardNeedsDevices(cfg, args, *si) { + return + } + devs := si.SplitDevs + if len(devs) == 0 { + devs = listEngineDevices(bin) + } + si.EngineVRAMMiB = engineDevsVRAM(devs) +} + // previewServeHelp : l'aide supposée d'un moteur récent, pour les aperçus de // l'éditeur (le process web ne lance pas le moteur pour lire son aide). Le // lancement, lui, décide sur la vraie aide. @@ -384,6 +440,14 @@ func moePreview(content string) (notes []string, load string) { extra := splitArgs(cfg["EXTRA_ARGS"]) probeCacheRAM(cfg, extra, &si) norm, _ := normalizeLoadFlags(extra, true) + // Les cartes du moteur, sans le lancer : la dernière liste complète qu'il a + // donnée à l'éditeur (devices.json). Absente : un avis, pas un refus. + if loadGuardNeedsDevices(cfg, norm, si) { + bin := prebuiltResolveBin(firstNonEmpty(cfg["BIN"], ReadConfig()["BIN"])) + if list, ok := devPersistGet(bin + "\x00" + cfg["CUDA_VISIBLE_DEVICES"]); ok { + si.EngineVRAMMiB = engineDevsVRAM(splitDevsFrom(list)) + } + } warn, refuse := loadModeRisk(cfg, norm, si) if refuse != "" { warn = "refusé au lancement — " + refuse diff --git a/internal/loki/backend_serve_moe_test.go b/internal/loki/backend_serve_moe_test.go index 00114ec..e807e81 100644 --- a/internal/loki/backend_serve_moe_test.go +++ b/internal/loki/backend_serve_moe_test.go @@ -111,10 +111,10 @@ func TestLoadModeRisk(t *testing.T) { {name: "sans drapeau : rien", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}}, {name: "none, 66 Gio certains en RAM pour 60 : refus (#26110)", - args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true}, + args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true}, {name: "même chose, LOAD_GUARD=off : avertissement seulement", cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none", - si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true}, + si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true}, {name: "mlock, 54 Gio en RAM pour 64 : avertissement", args: "--load-mode mlock", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true}, {name: "dense tout en VRAM sur une machine à peu de RAM : rien (pas de faux positif)", @@ -124,11 +124,11 @@ func TestLoadModeRisk(t *testing.T) { {name: "macOS, mémoire unifiée : tout le modèle compte", args: "--load-mode mmap+mlock", si: serveSysInfo{ModelBytes: 60 * gib, RAMMiB: 64 << 10, UnifiedMem: true}, refuse: true}, {name: "moteur ancien : --no-mmap", - args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true}, + args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true}, {name: "moteur ancien : --mlock --no-mmap", args: "--mlock --no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true}, {name: "LLAMA_ARG_NO_MMAP dans l'environnement", - si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10, + si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10, ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}, refuse: true}, // Corrections de relecture du lot 2 : pas de refus sur une borne fausse. {name: "LLAMA_ARG_NO_MMAP sans effet sur un moteur à --load-mode : rien", @@ -140,7 +140,13 @@ func TestLoadModeRisk(t *testing.T) { args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10, ArgEnv: map[string]string{"LLAMA_ARG_RPC": "10.0.0.2:50052"}}, warn: true}, {name: "limite du conteneur (RAM effective) : refus", - args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, VRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true}, + args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true}, + // Lot 3 : la VRAM comptée est celle des cartes du moteur ; nvidia-smi + // seul ne fait jamais refuser. + {name: "VRAM NVIDIA seule, cartes du moteur non lues : avertissement, jamais de refus", + args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true}, + {name: "Vulkan, NVIDIA 12 Gio + AMD 16 Gio listées par le moteur : rien (faux refus sur la seule VRAM NVIDIA)", + args: "--load-mode none", si: serveSysInfo{ModelBytes: 70 * gib, VRAMMiB: 12 << 10, EngineVRAMMiB: 28 << 10, RAMMiB: 60 << 10}}, {name: "taille inconnue : rien", args: "--load-mode none", si: serveSysInfo{VRAMMiB: 16 << 10, RAMMiB: 16 << 10}}, } @@ -275,7 +281,7 @@ func TestJoinArgsRoundTrip(t *testing.T) { // plus haut dans le journal. func TestModelLoadErrorLoadGuard(t *testing.T) { _, refuse := loadModeRisk(map[string]string{}, splitArgs("--load-mode none"), - serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}) + serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}) log := "llama_model_load: loading model\nmain: model loaded\nserver is listening\n[ERREUR] " + refuse + "\n" if got := modelLoadErrorFrom(log); !strings.Contains(got, "LOAD_GUARD=off") { t.Errorf("refus non signalé : %q", got) @@ -330,3 +336,48 @@ func TestLoadGuardRefusalExitStatus(t *testing.T) { } } } + +// Le refus envisagé sur la VRAM NVIDIA attend les cartes du moteur ; rien +// n'est lu sans refus en vue. +func TestLoadGuardNeedsDevices(t *testing.T) { + none := splitArgs("--load-mode none") + base := serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10} + if !loadGuardNeedsDevices(map[string]string{}, none, base) { + t.Error("refus en vue sur nvidia-smi : cartes du moteur non demandées") + } + confirmed := base + confirmed.EngineVRAMMiB = 16 << 10 + for name, c := range map[string]struct { + cfg map[string]string + args []string + si serveSysInfo + }{ + "déjà lues": {map[string]string{}, none, confirmed}, + "LOAD_GUARD=off": {map[string]string{"LOAD_GUARD": "off"}, none, base}, + "mmap": {map[string]string{}, splitArgs("--load-mode mmap"), base}, + "VRAM inconnue": {map[string]string{}, none, serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}}, + "loin du seuil": {map[string]string{}, none, serveSysInfo{ModelBytes: 30 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}}, + } { + if loadGuardNeedsDevices(c.cfg, c.args, c.si) { + t.Errorf("%s : cartes demandées sans refus en vue", name) + } + } +} + +// La VRAM des cartes du moteur : toutes additionnées, inconnue dès qu'une +// carte annonce 0 Mio (déjà pleine) ; devices.json relu en JSON (float64). +func TestEngineDevsVRAM(t *testing.T) { + mixed := splitDevsFrom([]map[string]any{ + {"id": "Vulkan0", "total_mib": 12288}, + {"id": "Vulkan1", "total_mib": float64(16368)}, + }) + if got := engineDevsVRAM(mixed); got != 12288+16368 { + t.Errorf("cartes mixtes : %d", got) + } + if got := engineDevsVRAM([]splitDev{{"CUDA0", 12288}, {"CUDA1", 0}}); got != 0 { + t.Errorf("carte à 0 Mio : %d, attendu inconnu", got) + } + if got := engineDevsVRAM(nil); got != 0 { + t.Errorf("liste vide : %d", got) + } +} diff --git a/internal/loki/backend_serve_split.go b/internal/loki/backend_serve_split.go index e455e15..ba2448b 100644 --- a/internal/loki/backend_serve_split.go +++ b/internal/loki/backend_serve_split.go @@ -98,17 +98,39 @@ func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) { 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 // sortie tronquée (carte pleine, moteur planté) : on ne s'y fie pas + return nil } - for _, d := range parseListDevices(string(out)) { + 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) - total, _ := d["total_mib"].(int) - si.SplitDevs = append(si.SplitDevs, splitDev{ID: id, TotalMiB: int64(total)}) + 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.