mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Frugalité : supprime le CPU consommé à vide (sondage groupé et adaptatif)
À vide, l'app chauffait le CPU en continu : trois sondages navigateur séparés (5/8/10 s) tournaient en permanence, y compris onglet caché, et chaque tick relançait un sous-processus nvidia-smi (cache TTL 5 s = la cadence, donc jamais efficace) plus deux connexions vers Ollama. - Nouveau /api/system/pulse : statut + ressources + modèles chargés en UNE requête (appels Ollama en parallèle, dégradation propre si muet), avec un cache court qui absorbe les rafales multi-onglets. - Front : un seul sondage, SUSPENDU quand l'onglet est caché, cadence adaptative (4 s pendant un flux, 20 s au repos) et rafraîchissement immédiat au retour sur l'onglet. - Panneau Matériel : 5 s -> 15 s et rien onglet caché. - Caches serveur alignés sur la cadence : nvidia-smi mis en cache 12 s, et _all_local_gpus (vue Matériel) n'était pas caché du tout. Effet au repos : ~25 requêtes/min et ~12 nvidia-smi/min -> ~3 et ~3 onglet visible, et ZÉRO onglet caché. Le serveur n'a aucune boucle de fond : sans navigateur ouvert, il ne consomme plus rien. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
5 files changed
+150
-25
No files matched your search
@@ -18,8 +18,12 @@ _NVIDIA_SMI = shutil.which("nvidia-smi")
|
|||||||
|
|
||||||
# Cache court : le front interroge /stats en continu ; relancer un sous-processus
|
# Cache court : le front interroge /stats en continu ; relancer un sous-processus
|
||||||
# nvidia-smi à chaque tick charge la machine qui héberge aussi Ollama.
|
# nvidia-smi à chaque tick charge la machine qui héberge aussi Ollama.
|
||||||
_GPU_CACHE_TTL = 5.0
|
# TTL volontairement > à la cadence de sondage, sinon le cache n'absorbe rien.
|
||||||
|
_GPU_CACHE_TTL = 12.0
|
||||||
_gpu_cache: dict = {"at": 0.0, "value": None}
|
_gpu_cache: dict = {"at": 0.0, "value": None}
|
||||||
|
# Même logique pour la vue matériel (sous-processus nvidia-smi plus lourd).
|
||||||
|
_HW_CACHE_TTL = 12.0
|
||||||
|
_hw_cache: dict = {"at": 0.0, "value": None}
|
||||||
|
|
||||||
|
|
||||||
async def _gpu_stats() -> dict | None:
|
async def _gpu_stats() -> dict | None:
|
||||||
@@ -69,10 +73,80 @@ async def stats() -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Battement groupé : absorbe les rafales (plusieurs onglets, reconnexions)
|
||||||
|
# sans multiplier les sous-processus ni les connexions vers Ollama.
|
||||||
|
_PULSE_TTL = 2.5
|
||||||
|
_pulse_cache: dict = {"at": 0.0, "value": None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _loaded_models() -> list[dict]:
|
||||||
|
"""Modèles chargés en mémoire + placement GPU (vide si Ollama muet)."""
|
||||||
|
try:
|
||||||
|
loaded = await ollama.ps()
|
||||||
|
except (httpx.HTTPError, OSError):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for m in loaded:
|
||||||
|
size = m.get("size", 0) or 0
|
||||||
|
vram = m.get("size_vram", 0) or 0
|
||||||
|
out.append({
|
||||||
|
"name": m.get("name") or m.get("model"),
|
||||||
|
"on_gpu": bool(size and vram >= size * 0.99),
|
||||||
|
"gpu_percent": int(vram / size * 100) if size else 0,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/pulse")
|
||||||
|
async def pulse() -> dict:
|
||||||
|
"""Battement unique : statut Ollama + ressources + modèles chargés.
|
||||||
|
|
||||||
|
Remplace trois sondages séparés (statut, stats, modèles chargés) par UNE
|
||||||
|
requête : moins de réveils, moins de sous-processus et moins de connexions
|
||||||
|
vers Ollama quand l'application est simplement ouverte sans être utilisée.
|
||||||
|
"""
|
||||||
|
if time.monotonic() - _pulse_cache["at"] < _PULSE_TTL and _pulse_cache["value"]:
|
||||||
|
return _pulse_cache["value"]
|
||||||
|
|
||||||
|
# Les deux appels Ollama partent ensemble : latence = le plus lent, pas la
|
||||||
|
# somme (important quand Ollama est sur une autre machine).
|
||||||
|
version, loaded = await asyncio.gather(
|
||||||
|
ollama.ping(), _loaded_models(), return_exceptions=True
|
||||||
|
)
|
||||||
|
connected = not isinstance(version, BaseException)
|
||||||
|
|
||||||
|
mem = psutil.virtual_memory()
|
||||||
|
value = {
|
||||||
|
"status": {
|
||||||
|
"connected": connected,
|
||||||
|
"host": ollama.host,
|
||||||
|
"version": version.get("version") if connected else None,
|
||||||
|
"default_model": settings.default_model,
|
||||||
|
**({} if connected else {"error": str(version)[:200]}),
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"cpu_pct": psutil.cpu_percent(interval=None),
|
||||||
|
"ram_used_go": round(mem.used / 1_000_000_000, 1),
|
||||||
|
"ram_total_go": round(mem.total / 1_000_000_000, 1),
|
||||||
|
"ram_pct": mem.percent,
|
||||||
|
"gpu": await _gpu_stats(),
|
||||||
|
},
|
||||||
|
"loaded": [] if isinstance(loaded, BaseException) else loaded,
|
||||||
|
}
|
||||||
|
_pulse_cache.update(at=time.monotonic(), value=value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
async def _all_local_gpus() -> list[dict]:
|
async def _all_local_gpus() -> list[dict]:
|
||||||
"""Tous les GPU NVIDIA visibles depuis le CONTENEUR Loki (peut être vide)."""
|
"""Tous les GPU NVIDIA visibles depuis le CONTENEUR Loki (peut être vide).
|
||||||
|
|
||||||
|
Mis en cache : le panneau Matériel se rafraîchit en continu tant qu'il est
|
||||||
|
ouvert, et chaque appel lançait un sous-processus nvidia-smi.
|
||||||
|
"""
|
||||||
if not _NVIDIA_SMI:
|
if not _NVIDIA_SMI:
|
||||||
return []
|
return []
|
||||||
|
if time.monotonic() - _hw_cache["at"] < _HW_CACHE_TTL:
|
||||||
|
return _hw_cache["value"] or []
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
_NVIDIA_SMI,
|
_NVIDIA_SMI,
|
||||||
@@ -90,8 +164,10 @@ async def _all_local_gpus() -> list[dict]:
|
|||||||
"vram_total_mb": float(total), "vram_used_mb": float(used),
|
"vram_total_mb": float(total), "vram_used_mb": float(used),
|
||||||
"util_pct": float(util),
|
"util_pct": float(util),
|
||||||
})
|
})
|
||||||
|
_hw_cache.update(at=time.monotonic(), value=gpus)
|
||||||
return gpus
|
return gpus
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_hw_cache.update(at=time.monotonic(), value=[])
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+37
-22
@@ -10,33 +10,48 @@ import { useStore } from "./store/useStore";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState<View>("chat");
|
const [view, setView] = useState<View>("chat");
|
||||||
const {
|
const { refreshPulse, refreshModels, refreshSessions } = useStore();
|
||||||
refreshStatus,
|
|
||||||
refreshSystemStats,
|
|
||||||
refreshModels,
|
|
||||||
refreshLoadedModels,
|
|
||||||
refreshSessions,
|
|
||||||
} = useStore();
|
|
||||||
|
|
||||||
// Au démarrage : statut Ollama + modèles + sessions. La config est chargée
|
// Sondage frugal. Trois principes, contre l'app qui chauffait le CPU à vide :
|
||||||
// par setSelectedModel (via refreshModels) — pas de double fetch. Poll du
|
// - UNE seule requête groupée (/api/system/pulse) au lieu de trois ;
|
||||||
// statut, des stats système (5 s, apparié au cache GPU serveur) et des
|
// - RIEN quand l'onglet est caché (l'utilisateur ne regarde pas) ;
|
||||||
// modèles chargés en VRAM (indicateur de préchargement).
|
// - cadence adaptative : réactif pendant le travail, lent au repos.
|
||||||
|
// Le serveur, lui, ne tourne jamais en boucle : sans navigateur, zéro CPU.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshStatus();
|
refreshPulse();
|
||||||
refreshSystemStats();
|
|
||||||
refreshModels();
|
refreshModels();
|
||||||
refreshLoadedModels();
|
|
||||||
refreshSessions();
|
refreshSessions();
|
||||||
const statusId = setInterval(refreshStatus, 10000);
|
|
||||||
const statsId = setInterval(refreshSystemStats, 5000);
|
let timer: number | undefined;
|
||||||
const loadedId = setInterval(refreshLoadedModels, 8000);
|
const tick = () => {
|
||||||
return () => {
|
// Onglet caché : on ne sonde pas du tout, on repassera au retour.
|
||||||
clearInterval(statusId);
|
if (document.hidden) return schedule();
|
||||||
clearInterval(statsId);
|
refreshPulse().finally(schedule);
|
||||||
clearInterval(loadedId);
|
|
||||||
};
|
};
|
||||||
}, [refreshStatus, refreshSystemStats, refreshModels, refreshLoadedModels, refreshSessions]);
|
const schedule = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
timer = window.setTimeout(schedule, 30000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pendant un flux, les indicateurs (VRAM, GPU) doivent suivre.
|
||||||
|
const busy = useStore.getState().streaming;
|
||||||
|
timer = window.setTimeout(tick, busy ? 4000 : 20000);
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
|
||||||
|
// Retour sur l'onglet : rafraîchit tout de suite plutôt que d'attendre.
|
||||||
|
const onVisible = () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", onVisible);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisible);
|
||||||
|
};
|
||||||
|
}, [refreshPulse, refreshModels, refreshSessions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-base text-ink">
|
<div className="flex h-full flex-col bg-base text-ink">
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ export async function getSystemStats(): Promise<SystemStats> {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Battement groupé : statut + ressources + modèles chargés en UNE requête. */
|
||||||
|
export interface Pulse {
|
||||||
|
status: OllamaStatus;
|
||||||
|
stats: SystemStats;
|
||||||
|
loaded: LoadedModel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPulse(): Promise<Pulse> {
|
||||||
|
const res = await fetch("/api/system/pulse");
|
||||||
|
if (!res.ok) throw new Error(`pulse ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Git du workspace ─────────────────────────────────────────────────────
|
// ── Git du workspace ─────────────────────────────────────────────────────
|
||||||
export interface GitCommit {
|
export interface GitCommit {
|
||||||
hash: string;
|
hash: string;
|
||||||
|
|||||||
@@ -627,9 +627,13 @@ function HardwareCard() {
|
|||||||
const [hw, setHw] = useState<HardwareInfo | null>(null);
|
const [hw, setHw] = useState<HardwareInfo | null>(null);
|
||||||
|
|
||||||
const refresh = () => getHardware().then(setHw).catch(() => {});
|
const refresh = () => getHardware().then(setHw).catch(() => {});
|
||||||
|
// 15 s et rien quand l'onglet est caché : chaque appel lance nvidia-smi côté
|
||||||
|
// serveur. Le bouton ↻ reste là pour un rafraîchissement immédiat.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
const id = setInterval(refresh, 5000);
|
const id = setInterval(() => {
|
||||||
|
if (!document.hidden) refresh();
|
||||||
|
}, 15000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getStatus,
|
getStatus,
|
||||||
getSystemStats,
|
getSystemStats,
|
||||||
getLoadedModels,
|
getLoadedModels,
|
||||||
|
getPulse,
|
||||||
warmModel,
|
warmModel,
|
||||||
fileContent,
|
fileContent,
|
||||||
listFiles,
|
listFiles,
|
||||||
@@ -71,6 +72,7 @@ interface LokiState {
|
|||||||
refreshStatus: () => Promise<void>;
|
refreshStatus: () => Promise<void>;
|
||||||
refreshSystemStats: () => Promise<void>;
|
refreshSystemStats: () => Promise<void>;
|
||||||
refreshLoadedModels: () => Promise<void>;
|
refreshLoadedModels: () => Promise<void>;
|
||||||
|
refreshPulse: () => Promise<void>;
|
||||||
refreshModels: () => Promise<void>;
|
refreshModels: () => Promise<void>;
|
||||||
refreshFiles: () => Promise<void>;
|
refreshFiles: () => Promise<void>;
|
||||||
|
|
||||||
@@ -261,6 +263,21 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
set({ loadedModels: await getLoadedModels() });
|
set({ loadedModels: await getLoadedModels() });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Sondage unique (statut + ressources + modèles chargés). Une seule requête
|
||||||
|
// au lieu de trois, et une seule mise à jour du store : moins de réveils
|
||||||
|
// navigateur et moins de rendus React quand l'app est ouverte à vide.
|
||||||
|
refreshPulse: async () => {
|
||||||
|
try {
|
||||||
|
const { status, stats, loaded } = await getPulse();
|
||||||
|
set({ status, systemStats: stats, loadedModels: loaded });
|
||||||
|
} catch {
|
||||||
|
set({
|
||||||
|
status: { connected: false, host: "", default_model: "" },
|
||||||
|
systemStats: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
refreshModels: async () => {
|
refreshModels: async () => {
|
||||||
set({ loadingModels: true });
|
set({ loadingModels: true });
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user