Compare commits

..
Author SHA1 Message Date
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00
LogiFlow a463ccbd9f Merge branch 'claude/wizardly-lamport-jfgrq5' : arrêt du moteur sans course avec la relance, VRAM rendue
Arrêt du moteur : attendre tout l'arbre (VRAM rendue avant la relance)
2026-10-08 10:08:13 +02:00
Claude 393004cb06 Arrêt du moteur : attendre tout l'arbre, pas seulement le chef
En conteneur, userSvcStop n'attendait que le processus `loki serve`. Avec
Strata, le moteur qui tient la VRAM (cache d'experts, ~9 Go) est un autre
processus qui finit de s'arrêter après le serveur Python : la relance
suivante démarrait llama-server sur une carte encore pleine (« cudaMalloc
failed: out of memory » à l'encodeur de vision) et Strata mesurait la RAM
disponible avec l'ancien moteur encore chargé (mode mmap choisi à tort).

stopProcTree relève l'arbre (session, groupe et descendants) avant tout
signal, SIGTERM puis SIGKILL aux survivants, et ne revient qu'une fois
tout parti.

Diagnostic : une pile d'appels citant load_model ne passe plus pour une
nouvelle tentative (la cause mémoire était perdue), et le chat donne
l'échec de chargement au lieu de « il démarre, réessaie ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 07:18:53 +00:00
MichaelandClaude Opus 5.5 7d614324a0 Merge branch 'fix/tune-lock-race' : CI verte, verrou de l'optimiseur sans course
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:11 +02:00
11 changed files with 363 additions and 17 deletions

No files matched your search

+1
View File
@@ -527,6 +527,7 @@ type serveSysInfo struct {
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
+22
View File
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
return
}
si.Draft = p
if a, err := os.Stat(p); err == nil {
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
si.DraftIsModel = true
}
}
if g, err := ggufMeta(p); err == nil {
si.DraftGGUF = &g
}
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
// (common_speculative_init_result charge tout fichier -md comme un modèle
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
// lieu de 22. On l'ignore : même tête, sans les poids en double.
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
c := make(map[string]string, len(cfg))
for k, v := range cfg {
c[k] = v
}
delete(c, "MODEL_DRAFT")
args, notes, auto = specArgs(c, extra, si)
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
}
mode := specMode(cfg)
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
+31
View File
@@ -1,6 +1,7 @@
package loki
import (
"os"
"reflect"
"slices"
"strings"
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
si: func(s *serveSysInfo) {
s.Draft = s.Model
s.DraftGGUF = s.GGUF
s.DraftIsModel = true
}},
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
t.Errorf("variable : %q", n)
}
}
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
func TestProbeSpecDraftIsModel(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
dir := t.TempDir()
model := dir + "/Swift-27B.gguf"
other := dir + "/mtp-Swift.gguf"
for _, p := range []string{model, other} {
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
if !si.DraftIsModel {
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
}
si = serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
if si.DraftIsModel || si.Draft != other {
t.Errorf("tête à part prise pour le modèle : %+v", si)
}
}
+15
View File
@@ -362,3 +362,18 @@ func TestStrataApplySettingsExperts(t *testing.T) {
t.Errorf("vide doit laisser le choix en place : %q", out)
}
}
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
}
}
+6
View File
@@ -1069,6 +1069,12 @@ func friendlyLLMError(err error) error {
}
func errEngineDown() error {
// Un refus de connexion ne veut pas toujours dire « il charge encore » : si
// le journal montre que le dernier lancement a échoué, c'est cela qu'il faut
// dire — réessayer dans quelques secondes n'y changera rien.
if msg := modelLoadError(); msg != "" {
return fmt.Errorf("⚠️ %s", msg)
}
return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
}
+166
View File
@@ -0,0 +1,166 @@
//go:build linux
package loki
import (
"os"
"strconv"
"strings"
"syscall"
"time"
)
// procRef : un processus repéré par son PID ET son instant de démarrage — un
// PID recyclé pendant l'attente ne passe pas pour le même processus.
type procRef struct {
pid int
start string
}
// procStat : les champs de /proc/<pid>/stat utiles ici.
type procStat struct {
state string
ppid, pgrp, sid int
start string
}
// readProcStat lit /proc/<pid>/stat. Le nom (champ 2, entre parenthèses) peut
// contenir des blancs et des parenthèses : on repart de la DERNIÈRE fermante.
func readProcStat(pid int) (procStat, bool) {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return procStat{}, false
}
return parseProcStat(string(b))
}
func parseProcStat(s string) (procStat, bool) {
i := strings.LastIndexByte(s, ')')
if i < 0 {
return procStat{}, false
}
// f[0] est le champ 3 (état), f[1] ppid, f[2] groupe, f[3] session, f[19]
// l'instant de démarrage (champ 22).
f := strings.Fields(s[i+1:])
if len(f) < 20 {
return procStat{}, false
}
ppid, _ := strconv.Atoi(f[1])
pgrp, _ := strconv.Atoi(f[2])
sid, _ := strconv.Atoi(f[3])
return procStat{state: f[0], ppid: ppid, pgrp: pgrp, sid: sid, start: f[19]}, true
}
// procTable : tous les processus vivants (les zombies ont fini de tourner : leur
// mémoire, VRAM comprise, est déjà rendue).
func procTable() map[int]procStat {
ents, err := os.ReadDir("/proc")
if err != nil {
return nil
}
t := make(map[int]procStat, len(ents))
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
if st, ok := readProcStat(pid); ok && st.state != "Z" {
t[pid] = st
}
}
return t
}
// procTreeOf : le chef root et tout ce qui en descend — sa session et son
// groupe (Setsid au lancement du service), plus les descendants par la
// filiation, pour un enfant qui aurait pris sa propre session (le serveur
// Python de Strata lance son moteur dans un processus à part). À relever AVANT
// d'envoyer le moindre signal : une fois le chef mort, ses orphelins sont
// rattachés à PID 1 et la filiation est perdue.
func procTreeOf(t map[int]procStat, root, self int) []procRef {
in := map[int]bool{}
for pid, st := range t {
if pid != self && (pid == root || st.sid == root || st.pgrp == root) {
in[pid] = true
}
}
// Fermeture par la filiation, jusqu'à ce que plus rien ne s'ajoute.
for grew := true; grew; {
grew = false
for pid, st := range t {
if !in[pid] && pid != self && in[st.ppid] {
in[pid] = true
grew = true
}
}
}
refs := make([]procRef, 0, len(in))
for pid := range in {
refs = append(refs, procRef{pid: pid, start: t[pid].start})
}
return refs
}
// procRefAlive : le processus tourne encore (ni disparu, ni zombie, ni
// remplacé par un autre sous le même PID).
func procRefAlive(r procRef) bool {
st, ok := readProcStat(r.pid)
return ok && st.state != "Z" && st.start == r.start
}
func procRefsAlive(refs []procRef) []procRef {
var alive []procRef
for _, r := range refs {
if procRefAlive(r) {
alive = append(alive, r)
}
}
return alive
}
// stopProcTree arrête le service lancé avec Setsid (pid = chef de session) et
// N'EN REVIENT QU'UNE FOIS TOUT L'ARBRE PARTI.
//
// Attendre le seul chef ne suffit pas : avec Strata, le chef est le serveur
// Python, et le moteur qui tient la VRAM (cache d'experts, plusieurs Go) est un
// autre processus qui finit de s'arrêter après lui. La relance qui suivait
// 500 ms plus tard démarrait alors llama-server sur une carte encore pleine —
// « cudaMalloc failed: out of memory » sur un modèle qui tient pourtant — et
// Strata mesurait la RAM disponible avec l'ancien moteur encore chargé.
//
// SIGTERM à tous, grace pour finir proprement, SIGKILL aux survivants, puis
// settle au plus pour que le noyau les ait vraiment retirés (un processus
// coincé dans une lecture disque ne meurt qu'à la sortie de l'appel système).
// Renvoie ceux qui tournent encore au-delà (rien à faire de plus, mais le dire).
func stopProcTree(pid int, grace, settle time.Duration) []procRef {
refs := procTreeOf(procTable(), pid, os.Getpid())
if len(refs) == 0 {
return nil
}
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for _, r := range refs {
_ = syscall.Kill(r.pid, syscall.SIGTERM)
}
if left := waitProcRefs(refs, grace); len(left) == 0 {
return nil
}
_ = syscall.Kill(-pid, syscall.SIGKILL)
for _, r := range procRefsAlive(refs) {
_ = syscall.Kill(r.pid, syscall.SIGKILL)
}
return waitProcRefs(refs, settle)
}
// waitProcRefs attend que tous aient disparu, au plus wait. Renvoie les survivants.
func waitProcRefs(refs []procRef, wait time.Duration) []procRef {
deadline := time.Now().Add(wait)
for {
left := procRefsAlive(refs)
if len(left) == 0 || time.Now().After(deadline) {
return left
}
time.Sleep(100 * time.Millisecond)
}
}
+83
View File
@@ -0,0 +1,83 @@
//go:build linux
package loki
import (
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestParseProcStat(t *testing.T) {
st, ok := parseProcStat("4242 (python3 (x) y) S 4200 4100 4000 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 987654 0 0")
if !ok || st.state != "S" || st.ppid != 4200 || st.pgrp != 4100 || st.sid != 4000 || st.start != "987654" {
t.Fatalf("lecture fausse : %+v ok=%v", st, ok)
}
if _, ok := parseProcStat("4242 (tronqué"); ok {
t.Error("ligne sans parenthèse fermante acceptée")
}
}
func TestProcTreeOf(t *testing.T) {
tab := map[int]procStat{
100: {ppid: 1, pgrp: 100, sid: 100, start: "a"}, // chef (Setsid)
101: {ppid: 100, pgrp: 100, sid: 100, start: "b"},
200: {ppid: 101, pgrp: 200, sid: 200, start: "c"}, // moteur dans sa propre session
201: {ppid: 200, pgrp: 200, sid: 200, start: "d"},
300: {ppid: 1, pgrp: 300, sid: 300, start: "e"}, // étranger
400: {ppid: 1, pgrp: 100, sid: 100, start: "f"}, // orphelin rattaché à PID 1
500: {ppid: 100, pgrp: 100, sid: 100, start: "g"}, // nous-mêmes
}
got := map[int]bool{}
for _, r := range procTreeOf(tab, 100, 500) {
got[r.pid] = true
}
for _, pid := range []int{100, 101, 200, 201, 400} {
if !got[pid] {
t.Errorf("PID %d oublié", pid)
}
}
if got[300] || got[500] {
t.Errorf("hors de l'arbre pris : %v", got)
}
}
// Le chef meurt vite, son enfant (dans une autre session, comme le moteur de
// Strata) traîne à s'arrêter : stopProcTree ne revient qu'une fois tout parti.
func TestStopProcTreeWaitsForChildren(t *testing.T) {
// Le chef lance un enfant qui ignore SIGTERM dans sa propre session, écrit
// son PID, puis attend.
pidFile := t.TempDir() + "/child"
script := `setsid sh -c 'trap "" TERM; echo $$ > ` + pidFile + `; while :; do sleep 0.1; done' & wait`
cmd := exec.Command("sh", "-c", script)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
t.Skip("sh indisponible :", err)
}
go cmd.Wait() // pas de zombie pour le chef
var child int
for i := 0; i < 50 && child == 0; i++ {
time.Sleep(50 * time.Millisecond)
if b, err := os.ReadFile(pidFile); err == nil {
child, _ = strconv.Atoi(strings.TrimSpace(string(b)))
}
}
if child == 0 {
t.Fatal("l'enfant n'a pas démarré")
}
st, ok := readProcStat(child)
if !ok || st.sid == cmd.Process.Pid {
t.Fatalf("l'enfant devait avoir sa propre session : %+v", st)
}
ref := procRef{pid: child, start: st.start}
if left := stopProcTree(cmd.Process.Pid, 300*time.Millisecond, 5*time.Second); len(left) != 0 {
t.Fatalf("survivants : %v", left)
}
if procRefAlive(ref) {
t.Fatal("l'enfant tourne encore au retour de stopProcTree")
}
}
+12 -12
View File
@@ -125,25 +125,25 @@ func userCheckStarted(pid int) error {
func userSvcStop(verbose bool) error {
pid := readServicePID()
if pid <= 0 || !processAlive(pid) {
// Le chef parti, ses enfants peuvent tourner encore (moteur de Strata) :
// on ne conclut « rien à arrêter » que si l'arbre entier est vide.
if pid <= 0 || len(procTreeOf(procTable(), pid, os.Getpid())) == 0 {
_ = os.Remove(pidFilePath())
if verbose {
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
}
return nil
}
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
// groupe entier, donc llama-server s'arrête avec lui.
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for i := 0; i < 40 && processAlive(pid); i++ {
time.Sleep(100 * time.Millisecond)
}
if processAlive(pid) {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
// trouver la VRAM et la RAM libres.
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
_ = os.Remove(pidFilePath())
if len(left) > 0 {
fmt.Fprintf(os.Stderr, "%s %d processus du moteur toujours là après SIGKILL (PID %d…) : "+
"la mémoire du GPU peut ne pas être encore rendue\n", yellow("[attention]"), len(left), left[0].pid)
}
if verbose {
fmt.Println(green("[ok]") + " arrêté")
}
+7 -2
View File
@@ -6635,7 +6635,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -6645,7 +6648,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
+7 -2
View File
@@ -1383,7 +1383,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -1393,7 +1396,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
+13 -1
View File
@@ -107,6 +107,13 @@ func modelLoadErrorFrom(log string) string {
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
start := 0
for i, l := range lines {
// Une pile d'appels cite les fonctions traversées
// (…server_context_impl10load_model…) : ce n'est pas une nouvelle
// tentative, et repartir de là perdait la cause écrite juste avant
// (« cudaMalloc failed: out of memory » devenait « a planté »).
if isBacktraceFrame(l) {
continue
}
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
// avant tout chargement : c'est lui, la dernière tentative.
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) ||
@@ -159,7 +166,8 @@ func modelLoadErrorFrom(log string) string {
strings.Contains(low, "cudamalloc failed"),
strings.Contains(low, "failed to allocate"):
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite"
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, " +
"et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)"
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
if reason == "" {
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
@@ -196,6 +204,10 @@ func modelLoadErrorFrom(log string) string {
}
}
// isBacktraceFrame : une ligne de pile d'appels de glibc
// (« /chemin/lib.so(symbole+0x1c)[0x14bd…] »).
func isBacktraceFrame(l string) bool { return strings.Contains(l, ")[0x") }
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu