Stockage : Loki signale un /data ou /models servi par le FUSE d'Unraid

Sur Unraid, /mnt/user/… n'est pas un disque mais shfs, un démon FUSE :
chaque fsync de loki.db et chaque page d'un gros MoE mmappé relue depuis
le disque (modèle plus gros que la RAM) le traverse, à chaque token. Rien
n'est perdu ni altéré, mais le décodage ralentit — sans que rien ne le
dise.

Loki ne déplace rien : changer le chemin hôte d'une installation donnerait
un /data vide. Il le voit et le dit, sur un ton d'information :
- sys_storagefuse.go lit /proc/self/mountinfo (type lu après le séparateur
  « - », montage le plus long qui contient le chemin) pour LOKI_HOME et
  chaque dossier de modèles ; Linux en conteneur seulement, mis en cache
  une minute puisque /api/status est interrogé en boucle ;
- conseil en jaune au démarrage et champ « hint » de /api/status, affiché
  en encart discret, distinct du bandeau rouge de perte de données ;
- compose Unraid et README : « Exclusive access » recommandé (même chemin,
  FUSE court-circuité), /mnt/<pool> en option avancée avec migration
  manuelle et mise en garde sur un pool inexistant (RAM).

La détection de thrash du cache de pages est laissée de côté : elle exige
un signal majflt calibré par modèle sur le serveur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 02:31:59 +02:00
1 parent 667e340e57
commit 056f307983
9 files changed
+333

No files matched your search

+177
View File
@@ -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/<pid>/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
}