diff --git a/README.md b/README.md index 93222c2..56d231d 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,23 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us emplacements différents dès que le partage n'est pas en cache-only ou que le *mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`. +**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des +partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue +depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace +utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage +(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information — +rien n'est perdu ni altéré. Pour l'éviter : + +- **recommandé** — passe le partage `appdata` (et celui des modèles) en + *Exclusive access* (Unraid 6.12+, partage sur un seul pool) : le chemin + `/mnt/user/…` ne change pas, FUSE est court-circuité ; +- **avancé** — mappe `/mnt//appdata/loki/…` (ex. `/mnt/cache` si ton + pool s'appelle `cache`), avec une migration manuelle : `Compose Down`, + `rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux + lignes `volumes`. Un `/mnt/` inexistant atterrit dans la RAM d'Unraid + (données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et + changer le chemin sans migrer donne un `/data` vide. + **Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`, diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index 4163fc9..b60a6f9 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -40,6 +40,21 @@ services: # rebinding). Inutile pour un accès par IP ou nom local. - LOKI_TRUSTED_HOSTS= volumes: + # PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages + # Unraid (shfs) : chaque écriture de la base et chaque page d'un gros + # modèle relue depuis le disque (modèle plus gros que la RAM) traverse un + # démon en espace utilisateur. Loki le signale au démarrage (encart + # d'information, rien n'est perdu). Deux remèdes, au choix : + # - Recommandé : Shares -> appdata (et le partage des modèles) -> + # « Exclusive access » = Yes (Unraid 6.12+, partage sur un seul + # pool). Le chemin /mnt/user reste le même, FUSE est court-circuité. + # - Avancé : mapper /mnt//appdata/loki/… (ex. /mnt/cache si + # ton pool s'appelle « cache »). Migration MANUELLE obligatoire : + # Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis + # changer les deux lignes ci-dessous. Un /mnt/ qui n'existe + # pas atterrit dans la RAM d'Unraid : données perdues au redémarrage, + # et un modèle de 80 Go peut saturer la mémoire du serveur. + # Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide. # /data : config, base, mémoire, workspace, modèles téléchargés par l'UI - /mnt/user/appdata/loki/data:/data # /models : dossier de .gguf déposés à la main (gros fichiers) diff --git a/internal/loki/sys_storagefuse.go b/internal/loki/sys_storagefuse.go new file mode 100644 index 0000000..0930d7c --- /dev/null +++ b/internal/loki/sys_storagefuse.go @@ -0,0 +1,177 @@ +package loki + +// sys_storagefuse.go — conseil de PERFORMANCE pour Unraid : /data et /models +// servis par la couche FUSE des partages (fuse.shfs). +// +// Sur Unraid, /mnt/user/… n'est pas un disque mais un démon en espace +// utilisateur (shfs) qui agrège array et pools. Tout y passe : chaque fsync de +// loki.db, et surtout chaque page du .gguf que le noyau doit relire quand un +// gros MoE mmappé ne tient pas en RAM — pendant le décodage, à chaque token. +// Rien de perdu, rien d'altéré (les octets du modèle sont les mêmes), mais tout +// est plus lent. +// +// Loki ne déplace RIEN : changer le chemin hôte d'une installation existante +// donnerait un /data vide et ferait croire à une perte de données (voir le +// README). Il se contente de le VOIR et de le DIRE, sur un ton d'information — +// pas le bandeau rouge de sys_datavolume.go, réservé à la perte de données. Le +// remède sans changement de chemin : passer les partages en « Exclusive +// access » (Unraid 6.12+), qui court-circuite shfs. + +import ( + "bufio" + "io" + "os" + "path" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +// mountEntry : ce qu'on retient d'une ligne de /proc/self/mountinfo. +type mountEntry struct { + point string // point de montage (5ᵉ champ), échappements décodés + fsType string // type de système de fichiers (1ᵉʳ champ après « - ») +} + +// parseMountinfo lit le format de /proc//mountinfo. Le nombre de champs +// optionnels (shared:N, master:N…) varie d'une ligne à l'autre : le type de +// système de fichiers se lit donc APRÈS le séparateur « - », jamais à un index +// fixe. Les lignes malformées sont ignorées. +func parseMountinfo(r io.Reader) []mountEntry { + var out []mountEntry + sc := bufio.NewScanner(r) + sc.Buffer(make([]byte, 64*1024), 1024*1024) + for sc.Scan() { + fields := strings.Fields(sc.Text()) + if len(fields) < 5 { + continue + } + sep := -1 + for i := 5; i < len(fields); i++ { + if fields[i] == "-" { + sep = i + break + } + } + if sep < 0 || sep+1 >= len(fields) { + continue + } + out = append(out, mountEntry{ + point: path.Clean(unescapeMountinfo(fields[4])), + fsType: fields[sep+1], + }) + } + return out +} + +// unescapeMountinfo décode les séquences octales du noyau (\040 espace, +// \011 tabulation, \012 saut de ligne, \134 antislash). +func unescapeMountinfo(s string) string { + if !strings.Contains(s, `\`) { + return s + } + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] == '\\' && i+3 < len(s) { + if v, err := strconv.ParseUint(s[i+1:i+4], 8, 8); err == nil { + b.WriteByte(byte(v)) + i += 3 + continue + } + } + b.WriteByte(s[i]) + } + return b.String() +} + +// mountFSType renvoie le type de système de fichiers qui porte path : celui du +// point de montage le plus LONG qui le contient (/data/models peut être un +// montage imbriqué distinct de /data). À longueur égale, la dernière ligne +// gagne — c'est le montage empilé par-dessus, celui qu'on voit vraiment. +// "" quand aucun montage ne couvre le chemin. Chemins Linux : package path, et +// non filepath, pour que les tests lisent la même chose sous Windows. +func mountFSType(mounts []mountEntry, target string) string { + p := path.Clean(target) + best, fs := -1, "" + for _, m := range mounts { + mp := m.point + if mp != "/" && p != mp && !strings.HasPrefix(p, mp+"/") { + continue + } + if len(mp) >= best { + best, fs = len(mp), m.fsType + } + } + return fs +} + +// shfsPaths renvoie, parmi paths, ceux qui passent par fuse.shfs (dédoublonnés, +// dans l'ordre reçu). +func shfsPaths(mounts []mountEntry, paths []string) []string { + var out []string + seen := map[string]bool{} + for _, p := range paths { + if strings.TrimSpace(p) == "" { + continue + } + p = path.Clean(p) + if seen[p] { + continue + } + seen[p] = true + if mountFSType(mounts, p) == "fuse.shfs" { + out = append(out, p) + } + } + return out +} + +// storageHintText formule le conseil pour les chemins concernés ("" si aucun). +// Conseil uniquement : les fichiers sont intacts, seule la vitesse en pâtit. +func storageHintText(paths []string) string { + if len(paths) == 0 { + return "" + } + return "ℹ️ Stockage via la couche FUSE des partages Unraid (fuse.shfs) : " + strings.Join(paths, ", ") + ". " + + "Les écritures de la base et les pages du modèle relues depuis le disque (gros modèle qui ne tient pas en RAM) " + + "traversent un démon en espace utilisateur, ce qui ralentit le décodage. Rien n'est perdu. " + + "Remède sans changer de chemin : partages appdata/modèles en « Exclusive access » (Unraid 6.12+). " + + "Voir docker-compose.unraid.yml." +} + +// storageHint mis en cache : /api/status est interrogé en boucle, et la liste +// des dossiers de modèles passe par la base. Les montages ne bougent pas pendant +// la vie du conteneur ; une minute suffit à refléter un dossier ajouté dans l'UI. +var storageHintCache struct { + sync.Mutex + at time.Time + val string +} + +// storageFuseHint renvoie le conseil « /data ou /models sur fuse.shfs » ("" si +// sans objet). Linux en conteneur seulement, comme dataVolumeWarning : hors +// conteneur, l'utilisateur gère ses chemins. +func storageFuseHint() string { + if runtime.GOOS != "linux" || os.Getenv("LOKI_CONTAINER") == "" { + return "" + } + c := &storageHintCache + c.Lock() + defer c.Unlock() + if !c.at.IsZero() && time.Since(c.at) < time.Minute { + return c.val + } + c.at = time.Now() + c.val = "" + f, err := os.Open("/proc/self/mountinfo") + if err != nil { + return "" // illisible : on ne conseille rien plutôt que de deviner + } + defer f.Close() + mounts := parseMountinfo(f) + paths := append([]string{LokiHome()}, modelDirs()...) + c.val = storageHintText(shfsPaths(mounts, paths)) + return c.val +} diff --git a/internal/loki/sys_storagefuse_test.go b/internal/loki/sys_storagefuse_test.go new file mode 100644 index 0000000..f63bf62 --- /dev/null +++ b/internal/loki/sys_storagefuse_test.go @@ -0,0 +1,100 @@ +package loki + +import ( + "runtime" + "strings" + "testing" +) + +// Extrait réaliste d'un conteneur Loki sur Unraid : racine overlay, /data et +// /models en bind depuis /mnt/user (fuse.shfs), un /data/models imbriqué sur +// un pool btrfs, champs optionnels de longueur variable, un chemin à espace. +const mountinfoUnraid = `612 553 0:156 / / rw,relatime master:221 - overlay overlay rw,lowerdir=/var/lib/docker/l +613 612 0:159 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw +620 612 0:52 /appdata/loki/data /data rw,noatime - fuse.shfs shfs rw,user_id=0,group_id=0,allow_other +621 612 0:52 /appdata/loki/models /models rw,noatime shared:5 master:7 - fuse.shfs shfs rw,user_id=0 +622 620 0:61 /appdata/loki/fast /data/models rw,noatime - btrfs /dev/nvme0n1p1 rw,ssd +623 612 0:52 /media /mnt/mes\040modeles rw,noatime - fuse.shfs shfs rw +garbage line +624 612 8:1 / /broken rw +` + +func TestParseMountinfo(t *testing.T) { + m := parseMountinfo(strings.NewReader(mountinfoUnraid)) + if len(m) != 6 { + t.Fatalf("attendu 6 montages valides, obtenu %d : %+v", len(m), m) + } + // Champs optionnels (shared:5 master:7) : le type se lit après « - ». + if m[3].point != "/models" || m[3].fsType != "fuse.shfs" { + t.Fatalf("ligne à champs optionnels mal lue : %+v", m[3]) + } + if m[5].point != "/mnt/mes modeles" { + t.Fatalf("échappement \\040 non décodé : %q", m[5].point) + } +} + +func TestMountFSType(t *testing.T) { + m := parseMountinfo(strings.NewReader(mountinfoUnraid)) + cases := []struct { + path, want string + }{ + {"/data", "fuse.shfs"}, + {"/data/loki.db", "fuse.shfs"}, + {"/data/models", "btrfs"}, // montage imbriqué : le plus long gagne + {"/data/models/x.gguf", "btrfs"}, + {"/data/modelsX", "fuse.shfs"}, // préfixe de chaîne ≠ préfixe de chemin + {"/models", "fuse.shfs"}, + {"/mnt/mes modeles/a.gguf", "fuse.shfs"}, + {"/opt/loki", "overlay"}, + } + for _, c := range cases { + if got := mountFSType(m, c.path); got != c.want { + t.Errorf("mountFSType(%q) = %q, attendu %q", c.path, got, c.want) + } + } + if got := mountFSType(nil, "/data"); got != "" { + t.Errorf("sans montage connu : %q, attendu \"\"", got) + } +} + +func TestShfsPaths(t *testing.T) { + m := parseMountinfo(strings.NewReader(mountinfoUnraid)) + got := shfsPaths(m, []string{"/data", "/data/models", "/models", "/models/", "/opt"}) + want := []string{"/data", "/models"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("shfsPaths = %v, attendu %v", got, want) + } + // Partages en « Exclusive access » : plus de fuse.shfs, plus de conseil. + excl := `1 0 0:1 / / rw - overlay overlay rw +2 1 0:2 /appdata/loki/data /data rw - btrfs /dev/nvme0n1p1 rw +3 1 0:3 /loki/models /models rw - xfs /dev/md1p1 rw +` + if p := shfsPaths(parseMountinfo(strings.NewReader(excl)), []string{"/data", "/models"}); len(p) != 0 { + t.Fatalf("aucun chemin ne devrait être signalé, obtenu %v", p) + } +} + +func TestStorageHintText(t *testing.T) { + if s := storageHintText(nil); s != "" { + t.Fatalf("rien à signaler : %q", s) + } + s := storageHintText([]string{"/data", "/models"}) + for _, w := range []string{"/data, /models", "fuse.shfs", "Exclusive access"} { + if !strings.Contains(s, w) { + t.Errorf("conseil sans %q : %s", w, s) + } + } +} + +func TestStorageFuseHintOutsideContainer(t *testing.T) { + t.Setenv("LOKI_CONTAINER", "") + if s := storageFuseHint(); s != "" { + t.Fatalf("hors conteneur, aucun conseil attendu : %q", s) + } + if runtime.GOOS != "linux" { + t.Setenv("LOKI_CONTAINER", "1") + if s := storageFuseHint(); s != "" { + t.Fatalf("hors Linux, aucun conseil attendu : %q", s) + } + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index beb31c1..151a818 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2301,6 +2301,7 @@ html[data-files="1"] #files-btn{color:var(--accent)} + +