From 4f3469440088bc69a893abaa2ab4cc7185b94264 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 01:57:55 +0200 Subject: [PATCH] =?UTF-8?q?Moniteur=20:=20un=20seul=20nvidia-smi=20pour=20?= =?UTF-8?q?tous=20les=20onglets,=20aucun=20pour=20un=20onglet=20cach=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque onglet ouvert (et chaque téléphone) interrogeait /api/vram toutes les 3 s, et chaque requête lançait son propre nvidia-smi — y compris depuis un onglet en arrière-plan que personne ne regarde, pendant que llama-server génère. Rien de tout cela ne touche au modèle : seules les jauges sont concernées. - gpuStatsCached : lecture partagée de 2 s, verrou tenu pendant la requête pour que des appels simultanés attendent la même lecture. nvidia-smi introuvable (Mac, CPU seul) mémorisé une minute ; une autre erreur se retente au délai normal. - Seul handleVram passe par le cache. Le déchargement (lecture « avant » et gpuSettle) garde des lectures fraîches : deux valeurs égales servies par le cache feraient conclure gpuSettle à tort. Le cache est vidé après un déchargement. - UI : statut, VRAM et RAM ne sont plus interrogés dans un onglet caché ; lecture immédiate au retour. - Tests : cache (réutilisation, erreur, binaire absent, appels simultanés) et échantillonneur du déchargement qui contourne le cache. Co-Authored-By: Claude Opus 5.5 --- internal/loki/ui/index.html | 14 +++- internal/loki/ui/src/js/09-stream.js | 14 +++- internal/loki/web_api.go | 5 +- internal/loki/web_vram.go | 100 ++++++++++++++++++++++++--- internal/loki/web_vram_test.go | 100 +++++++++++++++++++++++++++ 5 files changed, 214 insertions(+), 19 deletions(-) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index deb7adf..beb31c1 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -7942,9 +7942,17 @@ async function send(){ fail('échec de l\'envoi — réessaie'); } loadAll(); -setInterval(loadStatus, 5000); -setInterval(loadVram, 3000); -setInterval(loadRam, 3000); +// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne +// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque +// onglet ou téléphone laissé ouvert, pendant que le modèle génère. Au retour de +// l'onglet, une lecture immédiate remet tout à jour. +const pollVisible=(fn)=>()=>{ if(!document.hidden) fn(); }; +setInterval(pollVisible(loadStatus), 5000); +setInterval(pollVisible(loadVram), 3000); +setInterval(pollVisible(loadRam), 3000); +document.addEventListener('visibilitychange', ()=>{ + if(!document.hidden){ loadStatus(); loadVram(); loadRam(); } +}); // Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs. // « llama.cpp précompilé » = binaires officiels, aucune compilation // « llama.cpp compilé » = compilé ici, pour cette machine diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 99d82bd..d4bb0cd 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -770,6 +770,14 @@ async function send(){ fail('échec de l\'envoi — réessaie'); } loadAll(); -setInterval(loadStatus, 5000); -setInterval(loadVram, 3000); -setInterval(loadRam, 3000); +// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne +// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque +// onglet ou téléphone laissé ouvert, pendant que le modèle génère. Au retour de +// l'onglet, une lecture immédiate remet tout à jour. +const pollVisible=(fn)=>()=>{ if(!document.hidden) fn(); }; +setInterval(pollVisible(loadStatus), 5000); +setInterval(pollVisible(loadVram), 3000); +setInterval(pollVisible(loadRam), 3000); +document.addEventListener('visibilitychange', ()=>{ + if(!document.hidden){ loadStatus(); loadVram(); loadRam(); } +}); diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 8255b64..05ebb28 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -189,10 +189,11 @@ func handleServiceLog(w http.ResponseWriter, r *http.Request) { // handleVram expose l'état des cartes à l'interface (jauges du moniteur). // La lecture nvidia-smi elle-même vit dans web_vram.go, avec le déchargement -// de la VRAM qui s'en sert pour chiffrer la mémoire rendue. +// de la VRAM qui s'en sert pour chiffrer la mémoire rendue. Lecture partagée +// (gpuStatsCached) : tous les onglets se contentent d'un seul nvidia-smi. func handleVram(w http.ResponseWriter, r *http.Request) { gpus := []map[string]any{} - for _, g := range gpuStats() { + for _, g := range gpuStatsCached() { gpus = append(gpus, map[string]any{ "name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.Temp, }) diff --git a/internal/loki/web_vram.go b/internal/loki/web_vram.go index 2455faa..41d42ba 100644 --- a/internal/loki/web_vram.go +++ b/internal/loki/web_vram.go @@ -14,11 +14,13 @@ package loki import ( "encoding/json" + "errors" "net/http" "os/exec" "regexp" "strconv" "strings" + "sync" "time" ) @@ -32,15 +34,27 @@ type gpuStat struct { Temp int } -// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans -// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle -// prive juste le déchargement de son bilan chiffré. -func gpuStats() []gpuStat { - out, err := hideCmd(exec.Command("nvidia-smi", +// nvidiaSmiQuery lance la requête nvidia-smi. Variable pour que les tests +// substituent un faux exécutable et comptent les lancements. +var nvidiaSmiQuery = func() ([]byte, error) { + return hideCmd(exec.Command("nvidia-smi", "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits")).Output() +} + +// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans +// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle +// prive juste le déchargement de son bilan chiffré. Toujours une lecture +// FRAÎCHE : le bilan du déchargement (gpuSettle) en dépend, voir gpuStatsCached. +func gpuStats() []gpuStat { + gpus, _ := gpuStatsErr() + return gpus +} + +func gpuStatsErr() ([]gpuStat, error) { + out, err := nvidiaSmiQuery() if err != nil { - return nil + return nil, err } var gpus []gpuStat for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { @@ -58,7 +72,65 @@ func gpuStats() []gpuStat { g.Temp, _ = strconv.Atoi(parts[4]) gpus = append(gpus, g) } - return gpus + return gpus, nil +} + +// gpuCache : la lecture des jauges, partagée. Chaque onglet ouvert (et chaque +// téléphone) interroge /api/vram toutes les 3 s, et chacun lançait SON +// nvidia-smi — un processus qui réveille le pilote et vole quelques +// millisecondes de CPU aux threads de llama-server pendant une génération en +// partie sur CPU. Une lecture vieille de deux secondes suffit largement à une +// jauge ; le verrou tenu pendant la requête fait que des appels simultanés +// attendent la même lecture au lieu d'en lancer chacun une. +// +// Un nvidia-smi INTROUVABLE (Mac, CPU seul) est mémorisé une minute : inutile de +// rechercher le binaire dans le PATH toutes les deux secondes. Une autre erreur +// (carte en réinitialisation) se retente au bout du délai normal. +type gpuCache struct { + mu sync.Mutex + exp time.Time + gpus []gpuStat +} + +const ( + gpuCacheTTL = 2 * time.Second + gpuCacheAbsent = time.Minute +) + +// get : now est l'horloge (time.Now hors tests). +func (c *gpuCache) get(now func() time.Time, query func() ([]gpuStat, error)) []gpuStat { + c.mu.Lock() + defer c.mu.Unlock() + if now().Before(c.exp) { + return c.gpus + } + gpus, err := query() + ttl := gpuCacheTTL + if errors.Is(err, exec.ErrNotFound) { + ttl = gpuCacheAbsent + } + // L'échéance part de la FIN de la requête : un nvidia-smi lent ne doit pas + // laisser une lecture déjà vieille passer pour neuve. + c.gpus, c.exp = gpus, now().Add(ttl) + return c.gpus +} + +// invalidate : la prochaine lecture repart de nvidia-smi. +func (c *gpuCache) invalidate() { + c.mu.Lock() + c.exp = time.Time{} + c.mu.Unlock() +} + +var gpuMonitor gpuCache + +// gpuStatsCached : la lecture des jauges du moniteur (handleVram), SEULEMENT. +// Le déchargement garde gpuStats : gpuSettle conclut sur deux lectures égales +// de suite, et une valeur servie deux fois par le cache l'y ferait conclure à +// tort — exactement le faux bilan qu'il est écrit pour éviter. La tranche +// rendue est partagée : lecture seule. +func gpuStatsCached() []gpuStat { + return gpuMonitor.get(time.Now, gpuStatsErr) } // gpuUsedMB : VRAM occupée, toutes cartes confondues. @@ -113,10 +185,13 @@ func gpuSettle(before int, sample func() (int, bool), pause time.Duration) int { } func gpuUsedSettled(before int) int { - return gpuSettle(before, func() (int, bool) { - g := gpuStats() - return gpuUsedMB(g), g != nil - }, 500*time.Millisecond) + return gpuSettle(before, gpuSettleSample, 500*time.Millisecond) +} + +// gpuSettleSample : une lecture fraîche pour gpuSettle — jamais le cache. +func gpuSettleSample() (int, bool) { + g := gpuStats() + return gpuUsedMB(g), g != nil } // engineNeedsStop : faut-il envoyer « stop » ? Actif, évidemment. Mais sous @@ -204,6 +279,9 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) { if gpus != nil { after = gpuUsedSettled(before) } + // Les jauges ne doivent pas montrer, deux secondes encore, une carte pleine + // qu'on vient d'annoncer libérée. + gpuMonitor.invalidate() freed := before - after if freed < 0 { freed = 0 diff --git a/internal/loki/web_vram_test.go b/internal/loki/web_vram_test.go index 50cf614..c211cd7 100644 --- a/internal/loki/web_vram_test.go +++ b/internal/loki/web_vram_test.go @@ -1,10 +1,16 @@ package loki import ( + "errors" + "fmt" "net/http" "net/http/httptest" + "os/exec" "strings" + "sync" + "sync/atomic" "testing" + "time" ) // gpuSettle : le bilan « VRAM libérée » est calculé sur ces lectures. Chaque cas @@ -70,3 +76,97 @@ func TestVramRoutesRefusentGET(t *testing.T) { } } } + +// gpuCache : les jauges de tous les onglets se partagent une lecture par +// fenêtre de deux secondes, même quand les requêtes arrivent en même temps. +func TestGpuCache(t *testing.T) { + type step struct { + at time.Duration // horloge depuis le départ + err error // erreur rendue si la requête part + calls int32 // requêtes cumulées attendues après l'appel + } + absent := &exec.Error{Name: "nvidia-smi", Err: exec.ErrNotFound} + for _, c := range []struct { + name string + steps []step + }{ + {"lecture réutilisée puis rafraîchie", []step{ + {0, nil, 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2}, + }}, + // Carte en réinitialisation : on retente au délai normal, pas plus tard. + {"erreur retentée au délai normal", []step{ + {0, errors.New("exit status 9"), 1}, {time.Second, nil, 1}, {gpuCacheTTL, nil, 2}, + }}, + // Pas de nvidia-smi du tout : une recherche par minute suffit. + {"binaire absent mémorisé", []step{ + {0, absent, 1}, {30 * time.Second, absent, 1}, {gpuCacheAbsent, absent, 2}, + }}, + } { + t.Run(c.name, func(t *testing.T) { + var cache gpuCache + var calls int32 + t0 := time.Unix(1000, 0) + for i, st := range c.steps { + now := func() time.Time { return t0.Add(st.at) } + cache.get(now, func() ([]gpuStat, error) { + atomic.AddInt32(&calls, 1) + if st.err != nil { + return nil, st.err + } + return []gpuStat{{Name: "fake", Used: 1000}}, nil + }) + if got := atomic.LoadInt32(&calls); got != st.calls { + t.Fatalf("étape %d : %d requêtes, attendu %d", i, got, st.calls) + } + } + }) + } + + t.Run("appels simultanés", func(t *testing.T) { + var cache gpuCache + var calls int32 + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + g := cache.get(time.Now, func() ([]gpuStat, error) { + atomic.AddInt32(&calls, 1) + time.Sleep(20 * time.Millisecond) // nvidia-smi n'est pas instantané + return []gpuStat{{Name: "fake", Used: 1000}}, nil + }) + if len(g) != 1 || g[0].Used != 1000 { + t.Errorf("lecture partagée %+v", g) + } + }() + } + wg.Wait() + if calls != 1 { + t.Errorf("%d nvidia-smi lancés, attendu 1", calls) + } + }) +} + +// Le bilan du déchargement doit voir la mémoire PARTIR : son échantillonneur +// ne passe jamais par le cache des jauges, même tout juste rempli. +func TestGpuSettleSampleFrais(t *testing.T) { + orig := nvidiaSmiQuery + t.Cleanup(func() { nvidiaSmiQuery = orig; gpuMonitor = gpuCache{} }) + var calls int32 + nvidiaSmiQuery = func() ([]byte, error) { + n := atomic.AddInt32(&calls, 1) + return []byte(fmt.Sprintf("Fake GPU, %d, 24000, 0, 40\n", 20000-int(n)*1000)), nil + } + gpuMonitor = gpuCache{} + if g := gpuStatsCached(); len(g) != 1 || g[0].Used != 19000 { + t.Fatalf("jauges : %+v", g) + } + for want := 18000; want >= 17000; want -= 1000 { + if used, ok := gpuSettleSample(); !ok || used != want { + t.Errorf("échantillon = %d (%v), attendu %d frais", used, ok, want) + } + } + if g := gpuStatsCached(); g[0].Used != 19000 { + t.Errorf("jauges : %d, attendu la lecture en cache 19000", g[0].Used) + } +}