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>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 14:33:46 +02:00
1 parent a4db6d4da1
commit f0fef91773
5 files changed
+164 -16

No files matched your search

+9 -1
View File
@@ -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
+67 -3
View File
@@ -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
+57 -6
View File
@@ -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)
}
}
+26 -4
View File
@@ -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.