mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ea661d705 | ||
|
|
e236069c92 | ||
|
|
6afe07a823 | ||
|
|
30ce3a4e86 | ||
|
|
a463ccbd9f | ||
|
|
393004cb06 | ||
|
|
7d614324a0 | ||
|
|
853e59c085 | ||
|
|
e7fb63b1dd |
No files matched your search
@@ -120,6 +120,43 @@ verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
|
||||
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
||||
llama.cpp.
|
||||
|
||||
### Strata sur un SSD dédié (Unraid)
|
||||
|
||||
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
|
||||
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
|
||||
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
|
||||
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
|
||||
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
|
||||
et un disque dur de l'array est à proscrire.
|
||||
|
||||
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
|
||||
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
|
||||
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
|
||||
Le SSD est alors monté en `/mnt/strata`.
|
||||
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
|
||||
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
|
||||
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
|
||||
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
|
||||
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
|
||||
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
|
||||
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
|
||||
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
|
||||
les presets restent valides : le chemin vu par le conteneur ne change pas),
|
||||
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
|
||||
montage et occupe de la place pour rien.
|
||||
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
|
||||
```yaml
|
||||
- /mnt/strata/loki:/data/strata
|
||||
```
|
||||
(sous les deux volumes existants), puis *Compose Up*.
|
||||
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
|
||||
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
|
||||
proposées, puis installe.
|
||||
|
||||
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
|
||||
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
|
||||
ou cache : le pool `strata` n'est lié à aucun partage.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
|
||||
@@ -64,6 +64,15 @@ services:
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
- /mnt/user/appdata/loki/models:/models
|
||||
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
|
||||
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
|
||||
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
|
||||
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
|
||||
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
|
||||
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
|
||||
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
|
||||
# SSD dédié ».
|
||||
# - /mnt/strata/loki:/data/strata
|
||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,9 @@ const strataMinArch = 75
|
||||
func strataDetect() strataEnv {
|
||||
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
|
||||
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
|
||||
if f := diskFree(LokiHome()); f > 0 {
|
||||
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
|
||||
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
|
||||
if f := diskFree(strataHome()); f > 0 {
|
||||
env.DiskFreeGB = float64(f) / 1e9
|
||||
}
|
||||
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
||||
@@ -774,7 +776,10 @@ func strataRunInstall(req strataInstallReq) {
|
||||
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
|
||||
"--low-ram", "off",
|
||||
}
|
||||
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1"
|
||||
// XDG_CACHE_HOME : les caches de pip et de Hugging Face (~/.cache, soit
|
||||
// /root dans le conteneur, donc l'image Docker — 20 Go sur Unraid) suivent
|
||||
// le reste de Strata dans /data/strata.
|
||||
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1\x00XDG_CACHE_HOME=" + filepath.Join(strataHome(), "cache")
|
||||
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
|
||||
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,15 @@ func tuneActive() (tuneOwner, bool) {
|
||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||
var tuneHeld sync.Map
|
||||
|
||||
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
|
||||
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
|
||||
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
|
||||
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
|
||||
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
|
||||
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
|
||||
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
|
||||
var tuneFileMu sync.Mutex
|
||||
|
||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
|
||||
|
||||
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
||||
@@ -201,6 +210,7 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
key := tuneHeldKey(l.owner)
|
||||
tuneHeld.Store(key, true)
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
tuneFileMu.Lock()
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(l.owner)
|
||||
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
if werr != nil || cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
tuneHeld.Delete(key)
|
||||
tuneFileMu.Unlock()
|
||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||
}
|
||||
l.held = true
|
||||
tuneFileMu.Unlock()
|
||||
return l, nil
|
||||
}
|
||||
tuneFileMu.Unlock()
|
||||
if !os.IsExist(err) {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, err
|
||||
@@ -232,6 +245,13 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||
func (l *tuneLock) write() error {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
return l.writeLocked()
|
||||
}
|
||||
|
||||
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
|
||||
func (l *tuneLock) writeLocked() error {
|
||||
b, err := json.Marshal(l.owner)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
|
||||
func (l *tuneLock) release() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
if l.held {
|
||||
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
||||
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
||||
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
|
||||
if ours {
|
||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||
_ = l.write()
|
||||
_ = l.writeLocked()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||
break
|
||||
@@ -328,6 +350,24 @@ func (l *tuneLock) release() {
|
||||
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
|
||||
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||
func tuneReapStale() (mainWasActive bool) {
|
||||
o, ok := tuneReapClaim()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
}
|
||||
|
||||
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
|
||||
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
|
||||
// verrou périmé effectivement retiré (ok=false : rien à écarter).
|
||||
func tuneReapClaim() (tuneOwner, bool) {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
path := tuneLockPath()
|
||||
o, ok := readTuneOwner(path)
|
||||
if !ok {
|
||||
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
|
||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
if tuneOwnerAlive(o) {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||
if os.Rename(path, stale) != nil {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
defer os.Remove(stale)
|
||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
||||
@@ -358,14 +398,9 @@ func tuneReapStale() (mainWasActive bool) {
|
||||
if err := os.Link(stale, path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
|
||||
}
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
return o, true
|
||||
}
|
||||
|
||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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é")
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user