mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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>
This commit is contained in:
1 parent
889a85888d
commit
986a636c20
8 files changed
+480
-66
No files matched your search
@@ -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/<id>/`) : les pièces jointes
|
||||
|
||||
+120
-10
@@ -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=<fields> ». 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
|
||||
}
|
||||
if len(gpus) == 0 {
|
||||
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
|
||||
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])
|
||||
}
|
||||
return gpus, nil
|
||||
gpus = append(gpus, g)
|
||||
}
|
||||
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 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3428,6 +3428,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<div class="pe-gh">Cartes graphiques</div>
|
||||
<div class="pe-list" id="m-gpu-list"></div>
|
||||
<div class="pe-note" id="m-gpu-note"></div>
|
||||
<!-- Guide de placement (placementGuideHtml) : ordre des cartes, liaison
|
||||
PCIe, marges FIT_TARGET. Un conseil, jamais appliqué d'office. -->
|
||||
<div class="pe-note" id="m-gpu-guide" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 '<div class="pe-row">'
|
||||
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
|
||||
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
|
||||
+ '<span class="pe-sub" title="' + gpuEsc(gpuLinkTip(d)) + '">' + gpuEsc(d.id) + gb
|
||||
+ (link ? ' · ' + gpuEsc(link) : '') + '</span></span>'
|
||||
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
|
||||
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
|
||||
+ '</div>';
|
||||
@@ -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 = ['<b>Ordre vu par le moteur</b> : ' + order
|
||||
+ (sel.length === 2 ? ' · <button class="pe-link" onclick="reverseGpuOrder()">inverser l\'ordre</button>' : '')];
|
||||
parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la <b>dernière</b> 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 <b>première</b> 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('<br>');
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -1202,6 +1202,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div class="pe-gh">Cartes graphiques</div>
|
||||
<div class="pe-list" id="m-gpu-list"></div>
|
||||
<div class="pe-note" id="m-gpu-note"></div>
|
||||
<!-- Guide de placement (placementGuideHtml) : ordre des cartes, liaison
|
||||
PCIe, marges FIT_TARGET. Un conseil, jamais appliqué d'office. -->
|
||||
<div class="pe-note" id="m-gpu-guide" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 '<div class="pe-row">'
|
||||
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
|
||||
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
|
||||
+ '<span class="pe-sub" title="' + gpuEsc(gpuLinkTip(d)) + '">' + gpuEsc(d.id) + gb
|
||||
+ (link ? ' · ' + gpuEsc(link) : '') + '</span></span>'
|
||||
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
|
||||
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
|
||||
+ '</div>';
|
||||
@@ -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 = ['<b>Ordre vu par le moteur</b> : ' + order
|
||||
+ (sel.length === 2 ? ' · <button class="pe-link" onclick="reverseGpuOrder()">inverser l\'ordre</button>' : '')];
|
||||
parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la <b>dernière</b> 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 <b>première</b> 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('<br>');
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -115,52 +115,58 @@ 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)] {
|
||||
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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user