From dfdee26e4cca2db6b0fe116a92d3424b54cba201 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 30 Aug 2026 15:47:10 +0200 Subject: [PATCH] =?UTF-8?q?GPU=20:=20--list-devices=20qui=20plante=20en=20?= =?UTF-8?q?OOM=20faisait=20dispara=C3=AEtre=20la=202e=20carte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN --- internal/loki/web_devices.go | 86 +++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 6 deletions(-) diff --git a/internal/loki/web_devices.go b/internal/loki/web_devices.go index ec1eba6..938a30c 100644 --- a/internal/loki/web_devices.go +++ b/internal/loki/web_devices.go @@ -15,6 +15,7 @@ import ( "context" "encoding/json" "net/http" + "os" "os/exec" "path/filepath" "regexp" @@ -75,6 +76,45 @@ func devCachePut(bin string, devs []map[string]any) { 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 // 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 @@ -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"}) 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}) return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() 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() - if err != nil && len(out) == 0 { - sendJSON(w, 200, map[string]any{"ok": false, "error": "le moteur n'a pas répondu : " + err.Error()}) + devs := parseListDevices(string(out)) + // 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 } - devs := parseListDevices(string(out)) fillMissingMemory(devs) // 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 // pas dans le cache (sinon l'UI affiche « 0 Go » pendant dix minutes). 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}) }