GPU : --list-devices qui plante en OOM faisait disparaître la 2e carte

Moteur qui tourne, carte déjà pleine : l'énumération des cartes plante
en « CUDA error: out of memory » et ne rend qu'une liste tronquée — le
groupe Cartes graphiques de l'éditeur se cachait (moins de deux cartes)
et le tensor-split était perdu après un simple rechargement de l'UI,
qui vide le cache mémoire.

La dernière énumération réussie est persistée dans $LOKI_HOME/devices.json
et servie en repli (stale) quand le moteur sort en erreur. Identité, ordre
et mémoire totale sont des faits matériels stables ; seule la mémoire
libre y est périmée, sans importance pour répartir.

Repris de l'amont AJEAN v0.10.8 et v0.11.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-08-30 15:47:10 +02:00
1 parent cfe70f2b70
commit dfdee26e4c
1 file changed
+80 -6
+80 -6
View File
@@ -15,6 +15,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
@@ -75,6 +76,45 @@ func devCachePut(bin string, devs []map[string]any) {
devCacheMu.Unlock() devCacheMu.Unlock()
} }
// Cache PERSISTANT de la dernière énumération RÉUSSIE (exit 0) de --list-devices,
// par clé moteur+CVD. But : quand le moteur tourne et sature déjà une carte, un
// nouvel appel --list-devices peut PLANTER (CUDA out of memory en initialisant le
// device plein) et ne renvoyer qu'une partie des cartes — l'UI perdait alors le
// tensor split (slider caché faute de 2e GPU) après un simple redémarrage de
// l'interface, qui vide le cache mémoire. On garde donc sur disque la dernière
// liste complète : identité, ordre et mémoire TOTALE sont des faits matériels
// stables issus du moteur lui-même (pas de nvidia-smi, dont l'ordre peut différer,
// voir l'en-tête de ce fichier). Seule la mémoire LIBRE y est périmée, ce qui est
// sans importance pour choisir les cartes et régler la répartition.
var devPersistMu sync.Mutex
func devPersistPath() string { return filepath.Join(LokiHome(), "devices.json") }
func devPersistLoad() map[string][]map[string]any {
m := map[string][]map[string]any{}
if b, err := os.ReadFile(devPersistPath()); err == nil {
_ = json.Unmarshal(b, &m)
}
return m
}
func devPersistGet(key string) ([]map[string]any, bool) {
devPersistMu.Lock()
defer devPersistMu.Unlock()
d, ok := devPersistLoad()[key]
return d, ok && len(d) > 0
}
func devPersistPut(key string, devs []map[string]any) {
devPersistMu.Lock()
defer devPersistMu.Unlock()
m := devPersistLoad()
m[key] = devs
if b, err := json.MarshalIndent(m, "", " "); err == nil {
_ = os.WriteFile(devPersistPath(), b, 0o644)
}
}
// fillMissingMemory complète les mémoires que le moteur n'a pas su lire. Quand // fillMissingMemory complète les mémoires que le moteur n'a pas su lire. Quand
// une carte est déjà saturée par le modèle en cours, llama.cpp annonce 0 Mio — // une carte est déjà saturée par le modèle en cours, llama.cpp annonce 0 Mio —
// l'UI n'avait alors rien à afficher pour elle, ce qui donnait une liste // l'UI n'avait alors rien à afficher pour elle, ce qui donnait une liste
@@ -156,26 +196,60 @@ func handleBackendDevices(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "ce chemin n'est pas un llama-server"}) sendJSON(w, 400, map[string]any{"ok": false, "error": "ce chemin n'est pas un llama-server"})
return return
} }
if devs, ok := devCacheGet(bin); ok { // L'ordre d'énumération DOIT être celui du serveur en marche, sinon la liste
// affichée (et donc le --tensor-split que l'utilisateur règle carte par carte)
// se retrouve inversée par rapport à la réalité. Le moteur réel force
// CUDA_DEVICE_ORDER=PCI_BUS_ID (+ le filtre CUDA_VISIBLE_DEVICES) dans
// backend_serve.go ; par défaut CUDA classe « le plus rapide d'abord », ce qui
// peut être l'ordre INVERSE. On reproduit donc le même environnement ici.
cfg := ReadConfig()
cvd := cfg["CUDA_VISIBLE_DEVICES"]
cacheKey := bin + "\x00" + cvd
if devs, ok := devCacheGet(cacheKey); ok {
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs}) sendJSON(w, 200, map[string]any{"ok": true, "devices": devs})
return return
} }
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel() defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")) cmd := hideCmd(exec.CommandContext(ctx, bin, "--list-devices"))
cmd.Env = libraryPathEnv(filepath.Dir(bin)) env := libraryPathEnv(filepath.Dir(bin))
if cvd != "" {
env = append(env, "CUDA_VISIBLE_DEVICES="+cvd, "CUDA_DEVICE_ORDER=PCI_BUS_ID")
}
cmd.Env = env
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 { devs := parseListDevices(string(out))
sendJSON(w, 200, map[string]any{"ok": false, "error": "le moteur n'a pas répondu : " + err.Error()}) // err != nil = le moteur est sorti en erreur (typiquement il a PLANTÉ en OOM
// sur une carte déjà pleine pendant qu'il l'énumérait, cf. « CUDA error: out of
// memory »). La sortie est alors TRONQUÉE : on ne peut pas s'y fier (il manque
// des cartes). On rend plutôt la dernière liste complète connue, pour ne pas
// perdre le tensor split pendant que le moteur tourne.
if err != nil {
if good, ok := devPersistGet(cacheKey); ok {
sendJSON(w, 200, map[string]any{"ok": true, "devices": good, "stale": true})
return
}
if len(devs) == 0 {
sendJSON(w, 200, map[string]any{"ok": false, "error": "le moteur n'a pas répondu : " + err.Error()})
return
}
// Pas de repli disponible : on rend ce qu'on a lu, sans le figer (ni cache
// mémoire ni persistant) puisque la liste est probablement incomplète.
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs, "stale": true})
return return
} }
devs := parseListDevices(string(out))
fillMissingMemory(devs) fillMissingMemory(devs)
// Quand une carte est déjà saturée par le modèle en cours, le moteur peut // Quand une carte est déjà saturée par le modèle en cours, le moteur peut
// annoncer 0 Mio de mémoire : c'est une lecture transitoire, on ne la fige // annoncer 0 Mio de mémoire : c'est une lecture transitoire, on ne la fige
// pas dans le cache (sinon l'UI affiche « 0 Go » pendant dix minutes). // pas dans le cache (sinon l'UI affiche « 0 Go » pendant dix minutes).
if !hasZeroMemory(devs) { if !hasZeroMemory(devs) {
devCachePut(bin, devs) devCachePut(cacheKey, devs)
}
// Énumération propre (exit 0) = liste complète et faisant foi : on la garde sur
// disque comme repli pour les futurs appels où le moteur, chargé, ferait planter
// --list-devices.
if len(devs) > 0 {
devPersistPut(cacheKey, devs)
} }
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs}) sendJSON(w, 200, map[string]any{"ok": true, "devices": devs})
} }