diff --git a/README.md b/README.md index 0566045..c5268f5 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,26 @@ Ajoutées par ce fork : `SPEC`, `SIDE_SLOT`, `CTX` non chiffré, une seule carte ou une carte non CUDA, une architecture que llama.cpp exclut (et `qwen4exp`), un preset externe ; `SLOT_PERSIST` est refusé avec la clé. +- **Guide de placement entre cartes inégales** (éditeur de preset, groupe + « Cartes graphiques », dès deux cartes ; un conseil, rien n'est appliqué + d'office) : chaque carte CUDA affiche sa liaison PCIe maximale (génération, + largeur), l'actuelle et l'horloge mémoire en info-bulle — lues par + `nvidia-smi` en une requête, jointes par nom de carte (rien pour deux + cartes homonymes ni pour un moteur Vulkan), avec repli sur la requête + historique si un vieux pilote refuse un champ. `loki gpu` les affiche aussi. + Le guide rappelle les deux règles du moteur, dans l'ordre qu'il voit + (`--device` compris) : en placement auto, `--fit` remplit d'abord la + **dernière** carte, qui porte la couche de sortie (modèle dense : la plus + rapide en dernier, ou une marge `FIT_TARGET` plus large sur la lente) ; les + experts MoE en RAM sont recopiés au prefill vers la **première** (à elle la + liaison la plus large). Les marges `FIT_TARGET` du preset sont montrées carte + par carte ; un `--tensor-split` (qui désactive `--fit`) ou un + `CUDA_VISIBLE_DEVICES` propre au preset (ordre réel différent) est signalé. + Un lien « inverser l'ordre » écrit `--device` (et retourne `--tensor-split`) + dans le preset édité. Pas de bouton de mesure automatique : comparer deux + ordres demande deux rechargements du moteur et un ordre inversé peut manquer + de VRAM sur la petite carte — dupliquer le preset, inverser l'ordre dans la + copie, « bench complet » sur chacun, et vérifier `offloaded N/N layers`. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_gpu.go b/internal/loki/backend_gpu.go index 8c0d3d7..8fea0ff 100644 --- a/internal/loki/backend_gpu.go +++ b/internal/loki/backend_gpu.go @@ -2,11 +2,14 @@ package loki import ( "bufio" + "context" + "errors" "fmt" "os" "os/exec" "strconv" "strings" + "time" ) // backend_gpu.go — sélection du/des GPU utilisés par llama-server. @@ -26,6 +29,39 @@ type gpuInfo struct { MemTotal string // en MiB MemUsed string Cap string // compute capability + + // Liaison PCIe et horloge mémoire, pour guider le placement entre cartes + // inégales (voir annotateDevices). Zéro = inconnu : pilote trop ancien, + // « [N/A] », ou requête retombée sur les seuls champs historiques. + BusID string + LinkGen int // génération PCIe ACTUELLE — elle descend au repos (économie d'énergie) + LinkGenMax int // génération maximale négociable carte + carte mère + LinkWidth int // largeur actuelle (×N), elle aussi réduite au repos sur certaines cartes + LinkWidthMax int + MemClockMax int // horloge mémoire maximale, MHz +} + +// gpuBaseFields : la requête historique de detectGPUs, que tout pilote connaît. +// gpuLinkFields : les champs de liaison, ajoutés en fin de ligne. nvidia-smi +// refuse TOUTE la requête au moindre champ inconnu (« is not a valid field to +// query ») : la largeur du bus mémoire, qui n'existe pas, n'y figure donc pas, +// et un refus fait retomber sur la requête historique — jamais de GPU perdu +// pour une information de confort. +const ( + gpuBaseFields = "index,name,memory.total,memory.used,compute_cap" + gpuLinkFields = "pci.bus_id,pcie.link.gen.current,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,clocks.max.memory" +) + +// nvidiaSmiGPUQuery lance « nvidia-smi --query-gpu= ». Variable pour +// que les tests substituent un faux nvidia-smi. Borné dans le temps comme la +// lecture des jauges (nvidiaSmiTimeout) : un pilote coincé ne doit pas figer +// « loki gpu » ni l'éditeur de preset. +var nvidiaSmiGPUQuery = func(fields string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), nvidiaSmiTimeout) + defer cancel() + cmd := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu="+fields, "--format=csv,noheader,nounits")) + cmd.WaitDelay = time.Second + return cmd.Output() } func cmdGPU(args []string) error { @@ -83,6 +119,9 @@ func gpuList() error { for _, g := range gpus { mark := " " line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap) + if l := g.linkSummary(); l != "" { + line += " " + l + } active := sel == "" || selected[g.Index] if sel != "" && selected[g.Index] { mark = green("● ") @@ -131,14 +170,46 @@ func detectGPUs() ([]gpuInfo, error) { if !hasTool("nvidia-smi") { return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA") } - out, err := hideCmd(exec.Command("nvidia-smi", - "--query-gpu=index,name,memory.total,memory.used,compute_cap", - "--format=csv,noheader,nounits")).Output() + return queryGPUs(nvidiaSmiGPUQuery) +} + +// queryGPUs : le cœur de detectGPUs, la requête passée en paramètre pour être +// testée sans nvidia-smi. UNE invocation d'ordinaire : champs historiques et +// liaison ensemble. La seconde n'a lieu que si nvidia-smi refuse un champ de +// liaison (pilote ancien) — pas sur un délai dépassé, qu'une relance ne ferait +// que doubler. +func queryGPUs(query func(fields string) ([]byte, error)) ([]gpuInfo, error) { + out, err := query(gpuBaseFields + "," + gpuLinkFields) + if err != nil && smiFieldRefused(out, err) { + out, err = query(gpuBaseFields) + } if err != nil { return nil, fmt.Errorf("nvidia-smi a échoué: %w", err) } + gpus := parseGPUQuery(string(out)) + if len(gpus) == 0 { + return nil, fmt.Errorf("aucun GPU NVIDIA détecté") + } + return gpus, nil +} + +// smiFieldRefused : nvidia-smi a-t-il rejeté un champ de la requête ? Il +// l'écrit sur sa sortie standard et sort en code 2 (argument invalide). +func smiFieldRefused(out []byte, err error) bool { + if strings.Contains(string(out), "valid field") { + return true + } + var ee *exec.ExitError + return errors.As(err, &ee) && ee.ExitCode() == 2 +} + +// parseGPUQuery lit la sortie CSV de detectGPUs, avec ou sans les champs de +// liaison. Fonction pure. Une ligne de moins de cinq colonnes est ignorée, +// comme avant ; une valeur de liaison illisible (« [N/A] », « [Not +// Supported] ») vaut zéro, sans faire tomber la carte. +func parseGPUQuery(out string) []gpuInfo { var gpus []gpuInfo - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { parts := strings.Split(line, ",") if len(parts) < 5 { continue @@ -147,12 +218,51 @@ func detectGPUs() ([]gpuInfo, error) { parts[i] = strings.TrimSpace(parts[i]) } idx, _ := strconv.Atoi(parts[0]) - gpus = append(gpus, gpuInfo{ - Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4], - }) + g := gpuInfo{Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4]} + if len(parts) >= 11 { + if b := parts[5]; b != "" && b != "N/A" && !strings.HasPrefix(b, "[") { + g.BusID = b + } + g.LinkGen, g.LinkGenMax = smiNum(parts[6]), smiNum(parts[7]) + g.LinkWidth, g.LinkWidthMax = smiNum(parts[8]), smiNum(parts[9]) + g.MemClockMax = smiNum(parts[10]) + } + gpus = append(gpus, g) } - if len(gpus) == 0 { - return nil, fmt.Errorf("aucun GPU NVIDIA détecté") + return gpus +} + +// smiNum lit un entier de nvidia-smi ; « [N/A] », vide ou autre chose = 0. +func smiNum(s string) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 0 { + return 0 } - return gpus, nil + return n +} + +// linkSummary : la liaison PCIe en clair, « PCIe 4 ×16 », maximum d'abord — +// l'actuel descend au repos et ne dit rien de la carte sous charge. Vide = +// inconnue. +func (g gpuInfo) linkSummary() string { + if g.LinkGenMax == 0 && g.LinkWidthMax == 0 { + return "" + } + s := "PCIe" + if g.LinkGenMax > 0 { + s += " " + strconv.Itoa(g.LinkGenMax) + } + if g.LinkWidthMax > 0 { + s += " ×" + strconv.Itoa(g.LinkWidthMax) + } + if (g.LinkGen > 0 && g.LinkGen < g.LinkGenMax) || (g.LinkWidth > 0 && g.LinkWidth < g.LinkWidthMax) { + known := func(n int) string { + if n <= 0 { + return "?" + } + return strconv.Itoa(n) + } + s += " (actuellement " + known(g.LinkGen) + " ×" + known(g.LinkWidth) + ", réduit au repos)" + } + return s } diff --git a/internal/loki/backend_gpu_test.go b/internal/loki/backend_gpu_test.go new file mode 100644 index 0000000..d2df92e --- /dev/null +++ b/internal/loki/backend_gpu_test.go @@ -0,0 +1,88 @@ +package loki + +import ( + "errors" + "strings" + "testing" +) + +// La sortie enrichie : liaison et horloge lues, « [N/A] » et « [Not +// Supported] » valent zéro sans faire tomber la carte. +func TestParseGPUQueryLiaison(t *testing.T) { + out := `0, NVIDIA GeForce RTX 5060 Ti, 16311, 900, 12.0, 00000000:01:00.0, 1, 5, 8, 8, 14001 +1, NVIDIA GeForce RTX 3060, 12288, 300, 8.6, 00000000:05:00.0, [N/A], 4, [Not Supported], 16, [N/A] +` + g := parseGPUQuery(out) + if len(g) != 2 { + t.Fatalf("%d carte(s), attendu 2 : %+v", len(g), g) + } + if g[0].BusID != "00000000:01:00.0" || g[0].LinkGen != 1 || g[0].LinkGenMax != 5 || + g[0].LinkWidth != 8 || g[0].LinkWidthMax != 8 || g[0].MemClockMax != 14001 { + t.Errorf("carte 0 mal lue : %+v", g[0]) + } + if g[1].LinkGen != 0 || g[1].LinkGenMax != 4 || g[1].LinkWidth != 0 || g[1].LinkWidthMax != 16 || g[1].MemClockMax != 0 { + t.Errorf("valeurs [N/A] mal tolérées : %+v", g[1]) + } + if g[1].MemTotal != "12288" || g[1].Cap != "8.6" { + t.Errorf("champs historiques perdus : %+v", g[1]) + } + if s := g[0].linkSummary(); s != "PCIe 5 ×8 (actuellement 1 ×8, réduit au repos)" { + t.Errorf("résumé de liaison : %q", s) + } + if s := g[1].linkSummary(); s != "PCIe 4 ×16" { + t.Errorf("résumé sans valeur actuelle : %q", s) + } +} + +// La sortie historique (cinq colonnes) se lit comme avant, sans liaison. +func TestParseGPUQueryHistorique(t *testing.T) { + g := parseGPUQuery("0, NVIDIA GeForce RTX 3080 Ti, 12288, 1442, 8.6\n") + if len(g) != 1 || g[0].Name != "NVIDIA GeForce RTX 3080 Ti" || g[0].MemTotal != "12288" { + t.Fatalf("lecture historique cassée : %+v", g) + } + if g[0].LinkGenMax != 0 || g[0].BusID != "" || g[0].linkSummary() != "" { + t.Errorf("liaison inventée : %+v", g[0]) + } +} + +// Un pilote qui refuse un champ de liaison : la requête historique prend le +// relais, les cartes restent détectées. Une autre erreur (délai dépassé) n'est +// pas relancée. +func TestQueryGPUsRepli(t *testing.T) { + var calls []string + refuse := func(fields string) ([]byte, error) { + calls = append(calls, fields) + if strings.Contains(fields, "pcie") { + return []byte(`Field "pcie.link.gen.max" is not a valid field to query.` + "\n"), errors.New("exit status 2") + } + return []byte("0, NVIDIA GeForce RTX 3060, 12288, 300, 8.6\n"), nil + } + g, err := queryGPUs(refuse) + if err != nil || len(g) != 1 || g[0].Name != "NVIDIA GeForce RTX 3060" { + t.Fatalf("repli raté : %+v, %v", g, err) + } + if len(calls) != 2 || calls[1] != gpuBaseFields { + t.Errorf("requêtes : %q", calls) + } + + calls = nil + hang := func(fields string) ([]byte, error) { + calls = append(calls, fields) + return nil, errors.New("signal: killed") + } + if _, err := queryGPUs(hang); err == nil { + t.Error("erreur avalée") + } + if len(calls) != 1 { + t.Errorf("un délai dépassé relancé : %d requêtes", len(calls)) + } + + calls = nil + ok := func(fields string) ([]byte, error) { + calls = append(calls, fields) + return []byte("0, NVIDIA GeForce RTX 3060, 12288, 300, 8.6, 00000000:05:00.0, 4, 4, 16, 16, 7501\n"), nil + } + if g, err := queryGPUs(ok); err != nil || len(calls) != 1 || g[0].LinkWidthMax != 16 { + t.Errorf("une seule requête attendue : %d, %+v, %v", len(calls), g, err) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 5b3e518..15a736b 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3428,6 +3428,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Cartes graphiques
+ + @@ -5703,9 +5706,11 @@ function renderGpu(){ // Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le // modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge. const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : ''; + const link = gpuLinkText(d); return '
' + '' + gpuEsc(gpuLabel(d)) - + '' + gpuEsc(d.id) + gb + '' + + '' + gpuEsc(d.id) + gb + + (link ? ' · ' + gpuEsc(link) : '') + '' + '' + '
'; @@ -5715,10 +5720,83 @@ function renderGpu(){ document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length ? 'Toutes les cartes sont utilisées.' : 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.'; + const guide = document.getElementById('m-gpu-guide'); + if(guide){ guide.innerHTML = sel.length > 1 ? placementGuideHtml(sel) : ''; guide.hidden = sel.length < 2; } group.hidden = false; if(sel.length > 1) paintSplit(); } +// Liaison PCIe d'une carte (nvidia-smi, joint par nom côté serveur, CUDA +// seulement) : le MAXIMUM, seul parlant — l'actuel descend au repos. +function gpuLinkText(d){ + const l = d && d.link; + if(!l || !(l.gen_max || l.width_max)) return ''; + return 'PCIe' + (l.gen_max ? ' ' + l.gen_max : '') + (l.width_max ? ' ×' + l.width_max : ''); +} +function gpuLinkTip(d){ + const l = d && d.link; + if(!l) return ''; + const t = []; + if(l.gen || l.width) t.push('liaison actuelle : PCIe ' + (l.gen||'?') + ' ×' + (l.width||'?') + ' (réduite au repos, normale sous charge)'); + if(l.mem_clock_mhz) t.push('horloge mémoire max : ' + l.mem_clock_mhz + ' MHz'); + return t.join('\n'); +} + +// Guide de placement entre cartes inégales. CONSEIL seulement : rien n'est +// écrit dans le preset sans un clic, et Loki ne change jamais l'ordre ni les +// marges d'office. Deux règles du moteur, qui peuvent s'opposer : +// - --fit (placement auto) remplit les cartes en partant de la DERNIÈRE, qui +// porte aussi la couche de sortie : un modèle dense décode plus vite si la +// carte rapide est en dernier, ou si la lente garde une marge plus large ; +// - les experts MoE restés en RAM sont recopiés, au prefill, vers la PREMIÈRE +// carte : à elle la liaison PCIe la plus large. +// « Première » et « dernière » s'entendent dans l'ordre du moteur, --device +// compris — pas « CUDA0 ». +function placementGuideHtml(sel){ + const name = id => gpuEsc(gpuLabel(gpuById(id))); + const order = sel.map((id, i) => (i+1) + '. ' + name(id) + + (gpuLinkText(gpuById(id)) ? ' (' + gpuEsc(gpuLinkText(gpuById(id))) + ')' : '')).join(' → '); + const parts = ['Ordre vu par le moteur : ' + order + + (sel.length === 2 ? ' · ' : '')]; + parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la dernière carte, qui porte aussi ' + + 'la couche de sortie — la plus rapide en dernier, ou une marge FIT_TARGET plus large sur la lente ' + + '(ex. FIT_TARGET=1024,3072 quand la lente est seconde ; 1024 au minimum).'); + parts.push('• Experts MoE sur CPU : le prefill les recopie vers la première carte — à elle la liaison PCIe la plus large.'); + const ft = cfgReadKey('FIT_TARGET'); + if(ft){ + const v = ft.split(/[,/]/).map(s => s.trim()); + parts.push('FIT_TARGET : ' + (v.length === 1 ? gpuEsc(v[0]) + ' Mio sur chaque carte' + : sel.map((id, i) => name(id) + ' ' + gpuEsc(v[i] || '1024') + ' Mio').join(' · '))); + } + if(eaGetValued('--tensor-split')) + parts.push('--tensor-split fixe la répartition : --fit et FIT_TARGET ne jouent pas.'); + const cvd = cfgReadKey('CUDA_VISIBLE_DEVICES'); + if(cvd) parts.push('CUDA_VISIBLE_DEVICES=' + gpuEsc(cvd) + ' dans ce preset : l\'ordre réel peut différer de cette ' + + 'liste, établie avec la sélection de la machine — et les marges FIT_TARGET suivent l\'ordre réel.'); + parts.push('Les deux règles peuvent s\'opposer : à départager par la mesure, preset par preset. Loki ne bascule pas ' + + 'le moteur tout seul pour comparer (deux rechargements complets, et un ordre inversé peut manquer de VRAM sur la ' + + 'petite carte) : duplique le preset, inverse l\'ordre dans la copie, puis « bench complet » sur chacun. ' + + 'Vérifie « offloaded N/N layers » au journal.'); + return parts.join('
'); +} + +// Inverse l'ordre des deux cartes (--device), et la répartition avec elles. +// Geste explicite de l'utilisateur, écrit dans le preset édité seulement. +function reverseGpuOrder(){ + const sel = selectedGpuIds(); + if(sel.length !== 2) return; + const rev = sel.slice().reverse(); + eaSetValued('--device', gpuNaturalOrder(rev) ? '' : rev.join(',')); + const ts = eaGetValued('--tensor-split'); + if(ts) eaSetValued('--tensor-split', ts.split(',').reverse().join(',')); + renderGpu(); + toast('ordre : ' + rev.map(id => gpuLabel(gpuById(id))).join(' → ')); +} +// Toutes les cartes, dans l'ordre du moteur : aucune contrainte à écrire. +function gpuNaturalOrder(ids){ + return ids.length === gpuDevices.length && ids.every((id, i) => id === gpuDevices[i].id); +} + function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; } // Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1 // ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui @@ -5836,9 +5914,13 @@ function resetSplit(){ function onGpuPick(){ const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value); if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; } - // Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer - // des noms de device qui changeraient avec le moteur. - eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(',')); + // L'ordre déjà choisi (« inverser l'ordre ») est gardé pour les cartes restées + // cochées ; les nouvelles viennent à la suite, dans l'ordre du moteur. + const prev = eaGetValued('--device').split(',').filter(id => ids.includes(id)); + const order = prev.concat(ids.filter(id => !prev.includes(id))); + // Toutes cochées dans l'ordre du moteur = pas de contrainte : on retire le + // flag plutôt que de figer des noms de device qui changeraient avec le moteur. + eaSetValued('--device', gpuNaturalOrder(order) ? '' : order.join(',')); // Une répartition écrite pour N cartes n'a plus de sens pour N-1. if(ids.length < 2) eaSetValued('--tensor-split', ''); renderGpu(); diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 856826a..329818d 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -1202,6 +1202,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Cartes graphiques
+ + diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index eefbfbc..ae31113 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -732,9 +732,11 @@ function renderGpu(){ // Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le // modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge. const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : ''; + const link = gpuLinkText(d); return '
' + '' + gpuEsc(gpuLabel(d)) - + '' + gpuEsc(d.id) + gb + '' + + '' + gpuEsc(d.id) + gb + + (link ? ' · ' + gpuEsc(link) : '') + '' + '' + '
'; @@ -744,10 +746,83 @@ function renderGpu(){ document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length ? 'Toutes les cartes sont utilisées.' : 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.'; + const guide = document.getElementById('m-gpu-guide'); + if(guide){ guide.innerHTML = sel.length > 1 ? placementGuideHtml(sel) : ''; guide.hidden = sel.length < 2; } group.hidden = false; if(sel.length > 1) paintSplit(); } +// Liaison PCIe d'une carte (nvidia-smi, joint par nom côté serveur, CUDA +// seulement) : le MAXIMUM, seul parlant — l'actuel descend au repos. +function gpuLinkText(d){ + const l = d && d.link; + if(!l || !(l.gen_max || l.width_max)) return ''; + return 'PCIe' + (l.gen_max ? ' ' + l.gen_max : '') + (l.width_max ? ' ×' + l.width_max : ''); +} +function gpuLinkTip(d){ + const l = d && d.link; + if(!l) return ''; + const t = []; + if(l.gen || l.width) t.push('liaison actuelle : PCIe ' + (l.gen||'?') + ' ×' + (l.width||'?') + ' (réduite au repos, normale sous charge)'); + if(l.mem_clock_mhz) t.push('horloge mémoire max : ' + l.mem_clock_mhz + ' MHz'); + return t.join('\n'); +} + +// Guide de placement entre cartes inégales. CONSEIL seulement : rien n'est +// écrit dans le preset sans un clic, et Loki ne change jamais l'ordre ni les +// marges d'office. Deux règles du moteur, qui peuvent s'opposer : +// - --fit (placement auto) remplit les cartes en partant de la DERNIÈRE, qui +// porte aussi la couche de sortie : un modèle dense décode plus vite si la +// carte rapide est en dernier, ou si la lente garde une marge plus large ; +// - les experts MoE restés en RAM sont recopiés, au prefill, vers la PREMIÈRE +// carte : à elle la liaison PCIe la plus large. +// « Première » et « dernière » s'entendent dans l'ordre du moteur, --device +// compris — pas « CUDA0 ». +function placementGuideHtml(sel){ + const name = id => gpuEsc(gpuLabel(gpuById(id))); + const order = sel.map((id, i) => (i+1) + '. ' + name(id) + + (gpuLinkText(gpuById(id)) ? ' (' + gpuEsc(gpuLinkText(gpuById(id))) + ')' : '')).join(' → '); + const parts = ['Ordre vu par le moteur : ' + order + + (sel.length === 2 ? ' · ' : '')]; + parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la dernière carte, qui porte aussi ' + + 'la couche de sortie — la plus rapide en dernier, ou une marge FIT_TARGET plus large sur la lente ' + + '(ex. FIT_TARGET=1024,3072 quand la lente est seconde ; 1024 au minimum).'); + parts.push('• Experts MoE sur CPU : le prefill les recopie vers la première carte — à elle la liaison PCIe la plus large.'); + const ft = cfgReadKey('FIT_TARGET'); + if(ft){ + const v = ft.split(/[,/]/).map(s => s.trim()); + parts.push('FIT_TARGET : ' + (v.length === 1 ? gpuEsc(v[0]) + ' Mio sur chaque carte' + : sel.map((id, i) => name(id) + ' ' + gpuEsc(v[i] || '1024') + ' Mio').join(' · '))); + } + if(eaGetValued('--tensor-split')) + parts.push('--tensor-split fixe la répartition : --fit et FIT_TARGET ne jouent pas.'); + const cvd = cfgReadKey('CUDA_VISIBLE_DEVICES'); + if(cvd) parts.push('CUDA_VISIBLE_DEVICES=' + gpuEsc(cvd) + ' dans ce preset : l\'ordre réel peut différer de cette ' + + 'liste, établie avec la sélection de la machine — et les marges FIT_TARGET suivent l\'ordre réel.'); + parts.push('Les deux règles peuvent s\'opposer : à départager par la mesure, preset par preset. Loki ne bascule pas ' + + 'le moteur tout seul pour comparer (deux rechargements complets, et un ordre inversé peut manquer de VRAM sur la ' + + 'petite carte) : duplique le preset, inverse l\'ordre dans la copie, puis « bench complet » sur chacun. ' + + 'Vérifie « offloaded N/N layers » au journal.'); + return parts.join('
'); +} + +// Inverse l'ordre des deux cartes (--device), et la répartition avec elles. +// Geste explicite de l'utilisateur, écrit dans le preset édité seulement. +function reverseGpuOrder(){ + const sel = selectedGpuIds(); + if(sel.length !== 2) return; + const rev = sel.slice().reverse(); + eaSetValued('--device', gpuNaturalOrder(rev) ? '' : rev.join(',')); + const ts = eaGetValued('--tensor-split'); + if(ts) eaSetValued('--tensor-split', ts.split(',').reverse().join(',')); + renderGpu(); + toast('ordre : ' + rev.map(id => gpuLabel(gpuById(id))).join(' → ')); +} +// Toutes les cartes, dans l'ordre du moteur : aucune contrainte à écrire. +function gpuNaturalOrder(ids){ + return ids.length === gpuDevices.length && ids.every((id, i) => id === gpuDevices[i].id); +} + function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; } // Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1 // ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui @@ -865,9 +940,13 @@ function resetSplit(){ function onGpuPick(){ const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value); if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; } - // Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer - // des noms de device qui changeraient avec le moteur. - eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(',')); + // L'ordre déjà choisi (« inverser l'ordre ») est gardé pour les cartes restées + // cochées ; les nouvelles viennent à la suite, dans l'ordre du moteur. + const prev = eaGetValued('--device').split(',').filter(id => ids.includes(id)); + const order = prev.concat(ids.filter(id => !prev.includes(id))); + // Toutes cochées dans l'ordre du moteur = pas de contrainte : on retire le + // flag plutôt que de figer des noms de device qui changeraient avec le moteur. + eaSetValued('--device', gpuNaturalOrder(order) ? '' : order.join(',')); // Une répartition écrite pour N cartes n'a plus de sens pour N-1. if(ids.length < 2) eaSetValued('--tensor-split', ''); renderGpu(); diff --git a/internal/loki/web_devices.go b/internal/loki/web_devices.go index 938a30c..eef3dbd 100644 --- a/internal/loki/web_devices.go +++ b/internal/loki/web_devices.go @@ -115,50 +115,56 @@ func devPersistPut(key string, devs []map[string]any) { } } -// 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 -// incohérente (une carte avec sa taille, l'autre sans). +// annotateDevices complète les cartes listées par le moteur avec ce que +// nvidia-smi sait d'elles (UNE lecture, detectGPUs). Fonction pure. // -// On complète depuis nvidia-smi PAR CORRESPONDANCE DE NOM, et uniquement pour -// ça : la mémoire totale est une donnée matérielle fixe, indépendante 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. -func fillMissingMemory(devs []map[string]any) { - missing := false - for _, d := range devs { - if n, _ := d["total_mib"].(int); n <= 0 { - missing = true - } - } - if !missing { - return - } - gpus, err := detectGPUs() - if err != nil { - return - } - totals := map[string]int{} +// 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 := totals[name]; seen { + if _, seen := byName[name]; seen { dup[name] = true continue } - if mb, err := strconv.Atoi(strings.TrimSpace(g.MemTotal)); err == nil { - totals[name] = mb - } + byName[name] = g } for _, d := range devs { - if n, _ := d["total_mib"].(int); n > 0 { + name, _ := d["name"].(string) + name = strings.TrimSpace(name) + g, ok := byName[name] + if !ok || dup[name] { continue } - name, _ := d["name"].(string) - if mb, ok := totals[strings.TrimSpace(name)]; ok && !dup[strings.TrimSpace(name)] { - d["total_mib"] = mb + 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, } } } @@ -238,7 +244,12 @@ func handleBackendDevices(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true, "devices": devs, "stale": true}) return } - fillMissingMemory(devs) + // 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). diff --git a/internal/loki/web_devices_test.go b/internal/loki/web_devices_test.go index fd6ee51..d4526d7 100644 --- a/internal/loki/web_devices_test.go +++ b/internal/loki/web_devices_test.go @@ -44,32 +44,53 @@ warning: something else entirely } // Mémoire complétée : un device dont le moteur n'a pas su lire la taille (0 Mio, -// carte saturée) récupère celle de nvidia-smi par correspondance de nom. Deux -// cartes homonymes rendent la correspondance ambiguë : on ne complète pas. -func TestFillMissingMemoryParNom(t *testing.T) { +// carte saturée) récupère celle de nvidia-smi par correspondance de nom ; la +// liaison PCIe s'y ajoute, pour les cartes CUDA seulement. +func TestAnnotateDevicesParNom(t *testing.T) { devs := []map[string]any{ {"id": "CUDA0", "name": "NVIDIA GeForce RTX 5060 Ti", "total_mib": 0, "free_mib": 0}, {"id": "CUDA1", "name": "NVIDIA GeForce GTX 1650", "total_mib": 3715, "free_mib": 3659}, } - // La table de correspondance est celle que construit fillMissingMemory ; on - // vérifie ici la règle métier sans dépendre d'un nvidia-smi présent. - totals := map[string]int{"NVIDIA GeForce RTX 5060 Ti": 16311} - dup := map[string]bool{} - for _, d := range devs { - if n, _ := d["total_mib"].(int); n > 0 { - continue - } - name, _ := d["name"].(string) - if mb, ok := totals[name]; ok && !dup[name] { - d["total_mib"] = mb - } + gpus := []gpuInfo{ + {Name: "NVIDIA GeForce GTX 1650", MemTotal: "4096", LinkGen: 1, LinkGenMax: 3, LinkWidth: 16, LinkWidthMax: 16}, + {Name: "NVIDIA GeForce RTX 5060 Ti", MemTotal: "16311", LinkGen: 5, LinkGenMax: 5, LinkWidth: 8, LinkWidthMax: 8, MemClockMax: 14001}, } + annotateDevices(devs, gpus) if devs[0]["total_mib"] != 16311 { t.Errorf("mémoire non complétée : %v", devs[0]) } if devs[1]["total_mib"] != 3715 { t.Errorf("mémoire déjà connue écrasée : %v", devs[1]) } + l, _ := devs[0]["link"].(map[string]any) + if l == nil || l["gen_max"] != 5 || l["width_max"] != 8 || l["mem_clock_mhz"] != 14001 { + t.Errorf("liaison absente ou fausse : %v", devs[0]["link"]) + } + if l, _ := devs[1]["link"].(map[string]any); l == nil || l["width"] != 16 { + t.Errorf("liaison de la seconde carte : %v", devs[1]["link"]) + } +} + +// Deux cartes homonymes : correspondance ambiguë, rien n'est ajouté. Une carte +// Vulkan garde sa mémoire complétée mais pas de liaison. +func TestAnnotateDevicesAmbiguEtVulkan(t *testing.T) { + devs := []map[string]any{ + {"id": "CUDA0", "name": "NVIDIA GeForce RTX 3060", "total_mib": 0}, + {"id": "CUDA1", "name": "NVIDIA GeForce RTX 3060", "total_mib": 12288}, + } + twins := []gpuInfo{ + {Name: "NVIDIA GeForce RTX 3060", MemTotal: "12288", LinkGenMax: 4, LinkWidthMax: 16}, + {Name: "NVIDIA GeForce RTX 3060", MemTotal: "12288", LinkGenMax: 3, LinkWidthMax: 4}, + } + annotateDevices(devs, twins) + if devs[0]["total_mib"] != 0 || devs[0]["link"] != nil || devs[1]["link"] != nil { + t.Errorf("cartes homonymes annotées quand même : %v", devs) + } + vk := []map[string]any{{"id": "Vulkan0", "name": "NVIDIA GeForce RTX 3060", "total_mib": 0}} + annotateDevices(vk, twins[:1]) + if vk[0]["total_mib"] != 12288 || vk[0]["link"] != nil { + t.Errorf("carte Vulkan : %v", vk[0]) + } } // Le cache ne doit pas figer une lecture dégénérée (0 Mio) pendant 10 minutes.