Files
Loki/internal/loki/web_devices.go
T
MichaelandClaude Opus 5.5 986a636c20 Placement : liaison PCIe des cartes et guide d'ordre dans l'éditeur, en conseil
Deux cartes inégales (5060 Ti + 3060) se placent par deux règles du moteur
qui peuvent s'opposer : --fit remplit d'abord la DERNIÈRE carte, qui porte
la couche de sortie, et les experts MoE en RAM sont recopiés au prefill vers
la PREMIÈRE. Pour choisir l'ordre il faut voir la liaison de chaque carte ;
l'éditeur n'en montrait rien.

- detectGPUs lit aussi la liaison PCIe (génération et largeur, actuelles et
  maximales), le bus et l'horloge mémoire max, dans la même invocation de
  nvidia-smi. « [N/A] » vaut zéro ; un pilote qui refuse un champ fait
  retomber sur la requête historique (jamais de GPU perdu), un délai dépassé
  n'est pas relancé. Borné à 10 s. La largeur du bus mémoire n'existe pas
  dans nvidia-smi : absente.
- /api/backends/devices : une seule lecture nvidia-smi par énumération
  complète mémoire manquante ET liaison (annotateDevices, pure), par nom de
  carte, CUDA seulement, rien pour deux cartes homonymes. La lecture des
  jauges (gpuStatsCached) n'est pas touchée.
- Éditeur : liaison max par carte (l'actuelle et l'horloge en info-bulle),
  guide de placement dans l'ordre du moteur (--device compris), marges
  FIT_TARGET carte par carte, signalement d'un --tensor-split ou d'un
  CUDA_VISIBLE_DEVICES propre au preset, lien « inverser l'ordre » ; l'ordre
  choisi survit aux cases cochées.
- Pas de bouton de mesure : comparer deux ordres demande deux rechargements
  et un ordre inversé peut manquer de VRAM — le guide explique la marche à
  suivre (copie du preset, bench complet sur chacun).
- loki gpu affiche la liaison.

Rien ne change dans la ligne de commande du moteur.

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

267 lines
9.8 KiB
Go

// web_devices.go — liste des GPU tels que les voit UN moteur donné, pour que
// l'éditeur de modèle propose « quel(s) GPU utiliser » et le tensor split.
//
// Pourquoi interroger le binaire plutôt que nvidia-smi : les noms de device
// dépendent du backend compilé dans CE moteur, et l'ordre aussi. Sur la même
// machine, un build CUDA annonce « CUDA0 = RTX 5060 Ti, CUDA1 = GTX 1650 » là où
// le binaire précompilé (Vulkan) annonce l'inverse, « Vulkan0 = GTX 1650 ».
// Proposer une liste issue de nvidia-smi ferait donc choisir la mauvaise carte.
// C'est aussi pour ça que le réglage vit dans le PRESET (--device, compris par
// tous les backends) et non dans CUDA_VISIBLE_DEVICES, qui n'a aucun effet sur
// un moteur Vulkan.
package loki
import (
"context"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// deviceLine matche « CUDA0: NVIDIA GeForce RTX 5060 Ti (15849 MiB, 15579 MiB free) ».
var deviceLine = regexp.MustCompile(`^\s*([A-Za-z]+\d+):\s*(.+?)\s*\((\d+)\s*MiB,\s*(\d+)\s*MiB free\)\s*$`)
// parseListDevices extrait les devices de la sortie de `llama-server --list-devices`.
func parseListDevices(out string) []map[string]any {
devs := []map[string]any{}
for _, line := range strings.Split(out, "\n") {
m := deviceLine.FindStringSubmatch(strings.TrimRight(line, "\r"))
if m == nil {
continue
}
total, _ := strconv.Atoi(m[3])
free, _ := strconv.Atoi(m[4])
devs = append(devs, map[string]any{
"id": m[1], "name": m[2], "total_mib": total, "free_mib": free,
})
}
return devs
}
// Cache mémoire : lister les devices lance le moteur (init CUDA/Vulkan +
// énumération), soit une à trois secondes. Sans cache, l'encart « cartes
// graphiques » de l'éditeur apparaissait plusieurs secondes après le reste.
type devCacheEntry struct {
devices []map[string]any
at time.Time
}
var (
devCacheMu sync.Mutex
devCache = map[string]devCacheEntry{}
)
const devCacheTTL = 10 * time.Minute
func devCacheGet(bin string) ([]map[string]any, bool) {
devCacheMu.Lock()
defer devCacheMu.Unlock()
e, ok := devCache[bin]
if !ok || time.Since(e.at) > devCacheTTL {
return nil, false
}
return e.devices, true
}
func devCachePut(bin string, devs []map[string]any) {
devCacheMu.Lock()
devCache[bin] = devCacheEntry{devices: devs, at: time.Now()}
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)
}
}
// annotateDevices complète les cartes listées par le moteur avec ce que
// nvidia-smi sait d'elles (UNE lecture, detectGPUs). Fonction pure.
//
// Mémoire totale manquante : 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 incohérente (une carte avec sa taille, l'autre
// sans). Liaison PCIe et horloge mémoire (clé « link ») : de quoi choisir
// l'ordre des cartes en connaissance de cause (voir le guide de placement de
// l'éditeur), jamais appliqué d'office.
//
// Tout passe PAR CORRESPONDANCE DE NOM : ce sont des données matérielles fixes,
// indépendantes du backend. L'ordre et les identifiants des devices, eux,
// appartiennent au moteur (CUDA0 et Vulkan0 ne désignent pas la même carte) et
// ne doivent jamais venir de nvidia-smi. Un nom en double (deux cartes
// identiques) rend la correspondance ambiguë : on préfère alors ne rien dire.
// La liaison ne s'attache qu'aux cartes CUDA : un moteur Vulkan énumère dans
// son propre ordre, et le conseil qui l'accompagne parle de CUDA.
func annotateDevices(devs []map[string]any, gpus []gpuInfo) {
byName := map[string]gpuInfo{}
dup := map[string]bool{}
for _, g := range gpus {
name := strings.TrimSpace(g.Name)
if _, seen := byName[name]; seen {
dup[name] = true
continue
}
byName[name] = g
}
for _, d := range devs {
name, _ := d["name"].(string)
name = strings.TrimSpace(name)
g, ok := byName[name]
if !ok || dup[name] {
continue
}
if n, _ := d["total_mib"].(int); n <= 0 {
if mb, err := strconv.Atoi(strings.TrimSpace(g.MemTotal)); err == nil {
d["total_mib"] = mb
}
}
if id, _ := d["id"].(string); !strings.HasPrefix(id, "CUDA") {
continue
}
if g.LinkGenMax == 0 && g.LinkWidthMax == 0 && g.MemClockMax == 0 {
continue
}
d["link"] = map[string]any{
"gen": g.LinkGen, "gen_max": g.LinkGenMax,
"width": g.LinkWidth, "width_max": g.LinkWidthMax,
"mem_clock_mhz": g.MemClockMax,
}
}
}
// hasZeroMemory dit si au moins un device annonce une mémoire totale nulle.
func hasZeroMemory(devs []map[string]any) bool {
for _, d := range devs {
if n, _ := d["total_mib"].(int); n <= 0 {
return true
}
}
return false
}
// handleBackendDevices renvoie les devices vus par le moteur passé en `bin`
// (celui du preset en cours d'édition). Sans `bin`, on prend celui de la config
// active.
func handleBackendDevices(w http.ResponseWriter, r *http.Request) {
var req struct {
Bin string `json:"bin"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
bin := strings.TrimSpace(req.Bin)
if bin == "" {
bin = ReadConfig()["BIN"]
}
bin = prebuiltResolveBin(bin)
if bin == "" || !isFile(bin) {
sendJSON(w, 200, map[string]any{"ok": false, "error": "moteur introuvable — choisissez d'abord un moteur"})
return
}
// Garde-fou : on n'exécute que le serveur llama.cpp, pas n'importe quel
// chemin qui passerait par cette requête.
if base := strings.ToLower(filepath.Base(bin)); base != "llama-server" && base != "llama-server.exe" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "ce chemin n'est pas un llama-server"})
return
}
// 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"))
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()
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
}
// Une seule lecture nvidia-smi par énumération (et l'énumération est en
// cache dix minutes) : mémoire manquante et liaison PCIe ensemble. Sans
// nvidia-smi (Mac, AMD), rien n'est ajouté.
if gpus, gerr := detectGPUs(); gerr == nil {
annotateDevices(devs, gpus)
}
// 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(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})
}