mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 15:38:12 +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'à
|
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
||||||
llama.cpp.
|
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
|
## Installation sur Unraid
|
||||||
|
|
||||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ services:
|
|||||||
- /mnt/user/appdata/loki/data:/data
|
- /mnt/user/appdata/loki/data:/data
|
||||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||||
- /mnt/user/appdata/loki/models:/models
|
- /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
|
# 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.
|
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||||
ulimits:
|
ulimits:
|
||||||
|
|||||||
@@ -527,6 +527,7 @@ type serveSysInfo struct {
|
|||||||
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
||||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||||
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
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
|
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||||
|
|
||||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
si.Draft = p
|
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 {
|
if g, err := ggufMeta(p); err == nil {
|
||||||
si.DraftGGUF = &g
|
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).
|
// 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.
|
// 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) {
|
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)
|
mode := specMode(cfg)
|
||||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package loki
|
package loki
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
|
|||||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
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",
|
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
||||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
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"},
|
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)
|
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 {
|
func strataDetect() strataEnv {
|
||||||
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
|
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
|
||||||
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
|
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
|
env.DiskFreeGB = float64(f) / 1e9
|
||||||
}
|
}
|
||||||
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
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).
|
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
|
||||||
"--low-ram", "off",
|
"--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 {
|
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
|
||||||
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
|
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -362,3 +362,18 @@ func TestStrataApplySettingsExperts(t *testing.T) {
|
|||||||
t.Errorf("vide doit laisser le choix en place : %q", out)
|
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).
|
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||||
var tuneHeld sync.Map
|
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) }
|
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
|
// 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)
|
key := tuneHeldKey(l.owner)
|
||||||
tuneHeld.Store(key, true)
|
tuneHeld.Store(key, true)
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
tuneFileMu.Lock()
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
b, _ := json.Marshal(l.owner)
|
b, _ := json.Marshal(l.owner)
|
||||||
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
|||||||
if werr != nil || cerr != nil {
|
if werr != nil || cerr != nil {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
tuneHeld.Delete(key)
|
tuneHeld.Delete(key)
|
||||||
|
tuneFileMu.Unlock()
|
||||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||||
}
|
}
|
||||||
l.held = true
|
l.held = true
|
||||||
|
tuneFileMu.Unlock()
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
tuneFileMu.Unlock()
|
||||||
if !os.IsExist(err) {
|
if !os.IsExist(err) {
|
||||||
tuneHeld.Delete(key)
|
tuneHeld.Delete(key)
|
||||||
return nil, err
|
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
|
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||||
func (l *tuneLock) write() error {
|
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)
|
b, err := json.Marshal(l.owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
|
|||||||
func (l *tuneLock) release() {
|
func (l *tuneLock) release() {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
tuneFileMu.Lock()
|
||||||
|
defer tuneFileMu.Unlock()
|
||||||
if l.held {
|
if l.held {
|
||||||
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
// 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.
|
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
||||||
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
|
|||||||
if ours {
|
if ours {
|
||||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||||
_ = l.write()
|
_ = l.writeLocked()
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||||
break
|
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é). 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.
|
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||||
func tuneReapStale() (mainWasActive bool) {
|
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()
|
path := tuneLockPath()
|
||||||
o, ok := readTuneOwner(path)
|
o, ok := readTuneOwner(path)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
|
|||||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
}
|
}
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
if tuneOwnerAlive(o) {
|
if tuneOwnerAlive(o) {
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||||
if os.Rename(path, stale) != nil {
|
if os.Rename(path, stale) != nil {
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
defer os.Remove(stale)
|
defer os.Remove(stale)
|
||||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
// 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 {
|
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)
|
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) {
|
return o, true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
// 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 {
|
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())
|
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 {
|
func userSvcStop(verbose bool) error {
|
||||||
pid := readServicePID()
|
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())
|
_ = os.Remove(pidFilePath())
|
||||||
if verbose {
|
if verbose {
|
||||||
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
|
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
|
||||||
// groupe entier, donc llama-server s'arrête avec lui.
|
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
|
||||||
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
|
||||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
// trouver la VRAM et la RAM libres.
|
||||||
}
|
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
|
||||||
for i := 0; i < 40 && processAlive(pid); i++ {
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if processAlive(pid) {
|
|
||||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
|
||||||
}
|
|
||||||
_ = os.Remove(pidFilePath())
|
_ = 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 {
|
if verbose {
|
||||||
fmt.Println(green("[ok]") + " arrêté")
|
fmt.Println(green("[ok]") + " arrêté")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6635,7 +6635,10 @@ async function populateDraft(){
|
|||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
const list = await jget('/api/models');
|
const list = await jget('/api/models');
|
||||||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
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);
|
const hit = matchModel(cur, items);
|
||||||
let html = '<option value="">— aucun —</option>';
|
let html = '<option value="">— aucun —</option>';
|
||||||
let matched = false;
|
let matched = false;
|
||||||
@@ -6645,7 +6648,9 @@ async function populateDraft(){
|
|||||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||||
}
|
}
|
||||||
if(cur && !matched){
|
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;
|
sel.innerHTML = html;
|
||||||
syncSpecRow();
|
syncSpecRow();
|
||||||
|
|||||||
@@ -1383,7 +1383,10 @@ async function populateDraft(){
|
|||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
const list = await jget('/api/models');
|
const list = await jget('/api/models');
|
||||||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
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);
|
const hit = matchModel(cur, items);
|
||||||
let html = '<option value="">— aucun —</option>';
|
let html = '<option value="">— aucun —</option>';
|
||||||
let matched = false;
|
let matched = false;
|
||||||
@@ -1393,7 +1396,9 @@ async function populateDraft(){
|
|||||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||||
}
|
}
|
||||||
if(cur && !matched){
|
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;
|
sel.innerHTML = html;
|
||||||
syncSpecRow();
|
syncSpecRow();
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ func modelLoadErrorFrom(log string) string {
|
|||||||
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
|
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
|
||||||
start := 0
|
start := 0
|
||||||
for i, l := range lines {
|
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
|
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
|
||||||
// avant tout chargement : c'est lui, la dernière tentative.
|
// 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) ||
|
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, "cudamalloc failed"),
|
||||||
strings.Contains(low, "failed to allocate"):
|
strings.Contains(low, "failed to allocate"):
|
||||||
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
|
// 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"):
|
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
|
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
|
// 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
|
// 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
|
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu
|
||||||
|
|||||||
Reference in new issue
Block a user