mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Moniteur : un seul nvidia-smi pour tous les onglets, aucun pour un onglet caché
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3dff7e0e04
commit
4f34694400
5 files changed
+214
-19
No files matched your search
@@ -7942,9 +7942,17 @@ async function send(){
|
|||||||
fail('échec de l\'envoi — réessaie');
|
fail('échec de l\'envoi — réessaie');
|
||||||
}
|
}
|
||||||
loadAll();
|
loadAll();
|
||||||
setInterval(loadStatus, 5000);
|
// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne
|
||||||
setInterval(loadVram, 3000);
|
// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque
|
||||||
setInterval(loadRam, 3000);
|
// 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.
|
// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
|
||||||
// « llama.cpp précompilé » = binaires officiels, aucune compilation
|
// « llama.cpp précompilé » = binaires officiels, aucune compilation
|
||||||
// « llama.cpp compilé » = compilé ici, pour cette machine
|
// « llama.cpp compilé » = compilé ici, pour cette machine
|
||||||
|
|||||||
@@ -770,6 +770,14 @@ async function send(){
|
|||||||
fail('échec de l\'envoi — réessaie');
|
fail('échec de l\'envoi — réessaie');
|
||||||
}
|
}
|
||||||
loadAll();
|
loadAll();
|
||||||
setInterval(loadStatus, 5000);
|
// Jauges et pastille d'état : rien à rafraîchir dans un onglet que personne ne
|
||||||
setInterval(loadVram, 3000);
|
// regarde. Chaque tic lançait sinon un nvidia-smi côté serveur, pour chaque
|
||||||
setInterval(loadRam, 3000);
|
// 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(); }
|
||||||
|
});
|
||||||
@@ -189,10 +189,11 @@ func handleServiceLog(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// handleVram expose l'état des cartes à l'interface (jauges du moniteur).
|
// 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
|
// 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) {
|
func handleVram(w http.ResponseWriter, r *http.Request) {
|
||||||
gpus := []map[string]any{}
|
gpus := []map[string]any{}
|
||||||
for _, g := range gpuStats() {
|
for _, g := range gpuStatsCached() {
|
||||||
gpus = append(gpus, map[string]any{
|
gpus = append(gpus, map[string]any{
|
||||||
"name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.Temp,
|
"name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.Temp,
|
||||||
})
|
})
|
||||||
|
|||||||
+89
-11
@@ -14,11 +14,13 @@ package loki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,15 +34,27 @@ type gpuStat struct {
|
|||||||
Temp int
|
Temp int
|
||||||
}
|
}
|
||||||
|
|
||||||
// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans
|
// nvidiaSmiQuery lance la requête nvidia-smi. Variable pour que les tests
|
||||||
// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle
|
// substituent un faux exécutable et comptent les lancements.
|
||||||
// prive juste le déchargement de son bilan chiffré.
|
var nvidiaSmiQuery = func() ([]byte, error) {
|
||||||
func gpuStats() []gpuStat {
|
return hideCmd(exec.Command("nvidia-smi",
|
||||||
out, err := hideCmd(exec.Command("nvidia-smi",
|
|
||||||
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
|
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
|
||||||
"--format=csv,noheader,nounits")).Output()
|
"--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 {
|
if err != nil {
|
||||||
return nil
|
return nil, err
|
||||||
}
|
}
|
||||||
var gpus []gpuStat
|
var gpus []gpuStat
|
||||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
@@ -58,7 +72,65 @@ func gpuStats() []gpuStat {
|
|||||||
g.Temp, _ = strconv.Atoi(parts[4])
|
g.Temp, _ = strconv.Atoi(parts[4])
|
||||||
gpus = append(gpus, g)
|
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.
|
// 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 {
|
func gpuUsedSettled(before int) int {
|
||||||
return gpuSettle(before, func() (int, bool) {
|
return gpuSettle(before, gpuSettleSample, 500*time.Millisecond)
|
||||||
g := gpuStats()
|
}
|
||||||
return gpuUsedMB(g), g != nil
|
|
||||||
}, 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
|
// engineNeedsStop : faut-il envoyer « stop » ? Actif, évidemment. Mais sous
|
||||||
@@ -204,6 +279,9 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
|
|||||||
if gpus != nil {
|
if gpus != nil {
|
||||||
after = gpuUsedSettled(before)
|
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
|
freed := before - after
|
||||||
if freed < 0 {
|
if freed < 0 {
|
||||||
freed = 0
|
freed = 0
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package loki
|
package loki
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// gpuSettle : le bilan « VRAM libérée » est calculé sur ces lectures. Chaque cas
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user