From f87c7b615b5b54b739c10c7177e149d520d90432 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 11:12:56 +0200 Subject: [PATCH] MoE : avis de placement, copie en placement auto et garde du mode de chargement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un MoE aux experts sur CPU se règle aujourd'hui à la main (-ot, --n-cpu-moe 40, UBATCH 512) sans que Loki dise ce que --fit ferait mieux, ni qu'un --load-mode none/mlock trop gros pour la RAM mène au swap ou à l'OOM (llama.cpp #26110). Rien n'est réécrit en douce : des avis, une copie sur demande, un refus seulement quand l'échec est certain. - moeNotes (pure, comme nglArgs) : experts placés à la main → --fit ne les place pas, et ne pas monter UBATCH seul (tampon plus gros sur chaque carte) ; placement auto, MoE plus gros que la VRAM, UBATCH ≤ 512 → « UBATCH 2048+ » ; moteur sans --fit → « garde --n-cpu-moe » ; --fit coupé par un autre -ot. Le détecteur cpuExperts ne compte un -ot que s'il vise les experts (exps) vers le CPU : celui de per_layer_token_embd n'en est pas. - « Dupliquer en placement auto… » (éditeur, /api/preset/autoplace) : COPIE du preset affiché, sans -ot des experts, --n-cpu-moe, --cpu-moe, -ngl, --tensor-split, --fit off, -b/-ub ni drapeaux de chargement ; NGL retiré, UBATCH 2048, BATCH 4096, --load-mode mmap ; CTX, cache KV, échantillonnage intacts. Refusée si --fit resterait inactif ou si CTX vaut 0. L'original n'est pas touché, la copie n'est pas activée. - loadModeRisk : none, mlock, dio, mmap+mlock (et --no-mmap, --mlock, LLAMA_ARG_*) face à la RAM effective (limite cgroup comprise), en comptant modèle moins VRAM (tout le modèle sur macOS) : avertissement au-delà de 80 %, refus au lancement au-delà de 90 % de la borne basse seulement ; LOAD_GUARD=off le lève. VRAM inconnue : jamais de refus. - Éditeur : avis sous « Experts MoE sur CPU » et sous « Chargement ». Ligne de commande et environnement du moteur inchangés (test de non-régression) ; presets externes ignorés. Co-Authored-By: Claude Opus 5.5 --- README.md | 34 ++ internal/loki/backend_serve.go | 14 + internal/loki/backend_serve_cache.go | 3 +- internal/loki/backend_serve_moe.go | 499 ++++++++++++++++++++++++ internal/loki/backend_serve_moe_test.go | 260 ++++++++++++ internal/loki/sys_service.go | 3 + internal/loki/ui/index.html | 68 +++- internal/loki/ui/src/index.tmpl.html | 18 +- internal/loki/ui/src/js/07-models.js | 50 +++ internal/loki/web_api.go | 56 +++ internal/loki/web_server.go | 4 +- 11 files changed, 1001 insertions(+), 8 deletions(-) create mode 100644 internal/loki/backend_serve_moe.go create mode 100644 internal/loki/backend_serve_moe_test.go diff --git a/README.md b/README.md index c5268f5..d9243b2 100644 --- a/README.md +++ b/README.md @@ -604,6 +604,40 @@ Ajoutées par ce fork : ordres demande deux rechargements du moteur et un ordre inversé peut manquer de VRAM sur la petite carte — dupliquer le preset, inverser l'ordre dans la copie, « bench complet » sur chacun, et vérifier `offloaded N/N layers`. +- **MoE aux experts sur CPU : avis et copie en placement auto** (jamais de + réécriture du preset). Au lancement (journal) et dans l'éditeur, sous + « Experts MoE sur CPU », pour un modèle dont le GGUF annonce des experts : + experts placés à la main (`-ot` visant `…exps…` vers le CPU, `--n-cpu-moe`, + `--cpu-moe` — un `-ot` de la seule couche d'entrée `per_layer_token_embd` ne + compte pas) → `--fit` ne les place pas, et monter `UBATCH` seul grossirait + le tampon de calcul de chaque carte sans personne pour rééquilibrer (monter + `--n-cpu-moe` d'abord) ; placement auto avec un modèle plus gros que la VRAM + et `UBATCH` ≤ 512 → « UBATCH 2048+ recommandé » (les experts restés en RAM + sont recopiés vers le GPU à chaque micro-lot du prefill) ; moteur sans + `--fit` → « garde `--n-cpu-moe` » ; `--fit` coupé par un autre `-ot` → rien + ne sort les experts du GPU. Le bouton **« Dupliquer en placement auto… »** + crée une **copie** du preset affiché, sous « (placement auto) », sans la + bascule : `-ot` des experts (et celui de `per_layer_token_embd`, redondant : + llama.cpp garde toujours la couche d'entrée au CPU), `--n-cpu-moe`, + `--cpu-moe`, `-ngl`, `--tensor-split`, `--fit off`, `-b`/`-ub` et drapeaux + de chargement retirés ; `NGL` retiré (`-ngl auto`), `UBATCH=2048`, + `BATCH=4096`, `--load-mode mmap` ; `CTX`, cache KV (quantifié ou non : à + garder identique pour comparer), échantillonnage intacts. Refusé si la copie + ne tournerait pas en `--fit` (autre `-ot`, `-sm row/tensor`, `SPLIT_MODE`, + `--n-cpu-ffn`) ou si `CTX` vaut 0. Après bascule : `successfully fit params` + au journal (`failed to fit` = tous les experts sur GPU, revenir à + l'original), puis bench complet des deux. +- **Garde du mode de chargement** (clé d'échappement `LOAD_GUARD=off`, dans + le preset) : `--load-mode none`, `mlock`, `dio`, `mmap+mlock` (ou + `--no-mmap`, `--mlock` sur un moteur ancien, ou leurs `LLAMA_ARG_*`) gardent + en RAM tous les poids laissés au CPU. Loki en estime la part — modèle + (toutes tranches) moins VRAM totale, tout le modèle sur macOS — face à la + RAM effective (limite du conteneur cgroup comprise) : avertissement au-delà + de 80 % (llama.cpp #26110 : swap, décodage de 25 à 7,5 t/s ; mlock : processus + tué) ; **refus** au lancement seulement quand la borne basse dépasse 90 % + (échec certain), avec la raison en clair. VRAM inconnue : avertissement, jamais + de refus. `mmap` et `auto` ne sont jamais concernés. Rien ne change dans la + ligne de commande. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index b81fda8..667a6e4 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -342,6 +342,7 @@ func cmdServe(args []string) error { // launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse. probeServeGPUs(cfg, extra, &si) probeExpertEnv(&si) + probeLoadEnv(&si) probeFidelityEnv(&si) probeCacheRAM(cfg, extra, &si) probeCkptEnv(&si) @@ -385,6 +386,11 @@ func cmdServe(args []string) error { for _, n := range notes { fmt.Fprintln(os.Stderr, "[loki serve] "+n) } + // Chargement résident avec plus de poids en RAM que la machine n'en a : + // échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre. + if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" { + return fmt.Errorf("%s", refuse) + } // Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj // mmproj-F16.gguf) still resolve. @@ -444,6 +450,7 @@ type serveSysInfo struct { RAMMiB int64 // RAM de la machine, limite du conteneur comprise RAMAvailMiB int64 // RAM libre au lancement VRAMMiB int64 // VRAM NVIDIA des cartes visibles + UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk) SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau // Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un @@ -704,6 +711,13 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS notes = append(notes, n) } notes = append(notes, lossyCacheNotes(extra, si)...) + // Experts MoE et mode de chargement (voir backend_serve_moe.go) : des notes + // seulement, la ligne ne bouge pas. Le refus d'un chargement voué à l'échec + // se décide dans cmdServe, sur cette même ligne. + notes = append(notes, moeNotes(cfg, extra, si)...) + if w, _ := loadModeRisk(cfg, args, si); w != "" { + notes = append(notes, w) + } return args, env, notes } diff --git a/internal/loki/backend_serve_cache.go b/internal/loki/backend_serve_cache.go index f9e592e..1f64be9 100644 --- a/internal/loki/backend_serve_cache.go +++ b/internal/loki/backend_serve_cache.go @@ -74,9 +74,10 @@ func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) { } } } + si.UnifiedMem = runtime.GOOS == "darwin" // Mémoire unifiée (macOS), AMD, Vulkan, --device choisi à la main : pas de // total VRAM fiable, donc pas d'agrandissement. - if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" { + if si.UnifiedMem || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" { return } // La sélection du preset d'abord (cudaDeviceEnv l'accompagne toujours de diff --git a/internal/loki/backend_serve_moe.go b/internal/loki/backend_serve_moe.go new file mode 100644 index 0000000..c148822 --- /dev/null +++ b/internal/loki/backend_serve_moe.go @@ -0,0 +1,499 @@ +package loki + +import ( + "fmt" + "os" + "regexp" + "strconv" + "strings" +) + +// Experts MoE et chargement du modèle : des avis et un garde-fou, jamais de +// réécriture silencieuse du preset. +// +// moeNotes au lancement et dans l'éditeur : experts placés à la +// main (--fit ne les place pas), UBATCH conseillé quand +// --fit laisse des experts en RAM, moteur sans --fit +// loadModeRisk --load-mode none/mlock/dio/mmap+mlock (ou --no-mmap, +// --mlock) avec plus de poids en RAM que la machine n'en +// a : avertissement ; refus seulement si l'échec est +// certain, LOAD_GUARD=off pour passer outre +// autoPlacementPreset COPIE d'un preset MoE placé à la main, en placement +// automatique ; l'original n'est pas touché +// +// Rien de tout cela ne change ce que calcule le modèle : placement des poids +// et découpage des lots seulement, mêmes types de cache, même contexte. + +// otPatterns : chaque motif « regex=tampon » des -ot / --override-tensor (et +// de LLAMA_ARG_OVERRIDE_TENSOR) ; llama.cpp en accepte plusieurs par drapeau, +// séparés par des virgules. +func otPatterns(extra []string, argEnv map[string]string) []string { + vals := flagValues(extra, "-ot", "--override-tensor") + if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" { + vals = append(vals, v) + } + var out []string + for _, v := range vals { + for _, el := range strings.Split(v, ",") { + if el = strings.TrimSpace(el); el != "" { + out = append(out, el) + } + } + } + return out +} + +// otTargetsExperts : le motif vise-t-il les tenseurs d'experts (ffn_*_exps, +// ffn_*_chexps) ? Un motif qui ne vise que la couche d'entrée +// (per_layer_token_embd) n'en est pas. +func otTargetsExperts(pattern string) bool { + return strings.Contains(strings.ToLower(pattern), "exps") +} + +// otOnCPU : le tampon du motif est-il la RAM hôte (CPU, CPU_REPACK…) ? +func otOnCPU(el string) bool { + _, buft, ok := strings.Cut(el, "=") + return ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU") +} + +// cpuExperts dit comment des experts MoE sont envoyés en RAM à la main, ou +// vide. Plus étroit que cpuWeights : un -ot ne compte que s'il vise les +// experts, et le cache KV ou un NGL partiel n'en sont pas. +func cpuExperts(extra []string, argEnv map[string]string) string { + for _, el := range otPatterns(extra, argEnv) { + pat, _, _ := strings.Cut(el, "=") + if otTargetsExperts(pat) && otOnCPU(el) { + return "-ot experts=CPU" + } + } + if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) { + return "--cpu-moe" + } + for _, v := range append(flagValues(extra, "-ncmoe", "--n-cpu-moe"), argEnv["LLAMA_ARG_N_CPU_MOE"]) { + if v = strings.TrimSpace(v); v != "" && v != "0" { + return "--n-cpu-moe " + v + } + } + return "" +} + +// fitFlagRe : « --fit » lui-même dans l'aide, pas seulement --fit-target. +var fitFlagRe = regexp.MustCompile(`(^|[\s,])--fit([\s,=\[]|$)`) + +// helpSupportsFit : ce moteur sait-il placer les poids tout seul (--fit) ? +func helpSupportsFit(help string) bool { return fitFlagRe.MatchString(help) } + +// serveUBatch : le micro-lot que verra le moteur (-ub d'EXTRA_ARGS, sinon +// UBATCH, sinon le défaut de Loki). 0 = illisible. +func serveUBatch(cfg map[string]string, extra []string) int { + s := flagValue(extra, "-ub", "--ubatch-size") + if s == "" { + s = strings.TrimSpace(cfg["UBATCH"]) + } + if s == "" { + s = "512" + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 0 { + return 0 + } + return n +} + +// gibText : des Mio en « 12,3 Gio ». +func gibText(mib int64) string { + return strings.Replace(strconv.FormatFloat(float64(mib)/1024, 'f', 1, 64), ".", ",", 1) + " Gio" +} + +// moeNotes : ce qu'il faut savoir du placement d'un MoE, pour ce lancement. +// Fonction pure, comme nglArgs : des notes seulement, aucun drapeau. +// +// - experts placés à la main (-ot visant les experts, --n-cpu-moe, +// --cpu-moe) : --fit ne les place pas. Monter UBATCH seul y serait un +// piège : le tampon de calcul grossit sur CHAQUE carte, sans personne pour +// rééquilibrer, et le moteur finit en « cudaMalloc failed » ; +// - placement auto, modèle plus gros que la VRAM : --fit laisse des experts +// en RAM, recopiés vers le GPU à chaque micro-lot du prefill — un UBATCH de +// 2048 ou plus en paie quatre fois moins. --fit, lui, retient de la place +// pour ce tampon plus gros ; +// - moteur sans --fit (ancien, fork) : rien ne sort les experts du GPU, +// garder --n-cpu-moe ; +// - --fit coupé par un autre -ot : même chose. +// +// Muette sur un modèle dense, un preset externe, une VRAM inconnue (macOS, +// --device, AMD) ou une aide du moteur illisible. +func moeNotes(cfg map[string]string, extra []string, si serveSysInfo) []string { + if isExternalConfig(cfg) || si.GGUF == nil || si.GGUF.ExpertCount <= 0 { + return nil + } + ub := serveUBatch(cfg, extra) + if r := cpuExperts(extra, si.ArgEnv); r != "" { + n := "experts placés à la main (" + r + ") : --fit ne les place pas" + if ub > 0 && ub <= 512 { + n += " ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, " + + "ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + } + return []string{n} + } + modelMiB := si.ModelBytes >> 20 + if modelMiB <= 0 || si.VRAMMiB <= 0 || si.UnifiedMem || strings.TrimSpace(si.Help) == "" { + return nil + } + cards := int64(si.GPUs) + if cards < 1 { + cards = 1 + } + if modelMiB <= si.VRAMMiB-fitTargetMin*cards { + return nil // tout tient sur GPU : pas d'expert recopié + } + sizes := "modèle " + gibText(modelMiB) + ", VRAM " + gibText(si.VRAMMiB) + if !helpSupportsFit(si.Help) { + // Un NGL chiffré sous 999 laisse déjà des couches (et leurs experts) au + // CPU : l'utilisateur a tranché. + if n, err := strconv.Atoi(strings.TrimSpace(firstNonEmpty(flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"), cfg["NGL"]))); err == nil && n < 999 { + return nil + } + return []string{"MoE plus gros que la VRAM (" + sizes + ") : ce moteur ne place pas les experts (pas de --fit) — " + + "garde --n-cpu-moe"} + } + if why := fitBlocker(cfg, extra, si); why != "" { + if why == "surcharge de tenseurs" { + return []string{"MoE plus gros que la VRAM (" + sizes + ") et --fit coupé par un -ot qui ne vise pas les " + + "experts : rien ne les sort du GPU — --n-cpu-moe, ou retire ce -ot"} + } + return nil + } + if ub > 0 && ub <= 512 { + return []string{"UBATCH 2048+ recommandé : --fit laisse des experts en RAM (" + sizes + "), recopiés vers le " + + "GPU à chaque micro-lot du prefill ; BATCH 4096 avec — à mesurer (bench complet)"} + } + return nil +} + +// firstNonEmpty : la première valeur non vide. +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// loadArgEnv : les variables qui règlent le chargement sans drapeau. +var loadArgEnv = []string{"LLAMA_ARG_LOAD_MODE", "LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP"} + +// probeLoadEnv relève ces variables. Appelée après probeServeGPUs, qui +// remet ArgEnv à zéro. +func probeLoadEnv(si *serveSysInfo) { + if si.ArgEnv == nil { + si.ArgEnv = map[string]string{} + } + for _, k := range loadArgEnv { + if v := strings.TrimSpace(os.Getenv(k)); v != "" { + si.ArgEnv[k] = v + } + } +} + +// residentLoadMode : le mode de chargement qui garde ENTIÈREMENT en RAM les +// poids laissés au CPU, tel qu'écrit (« --load-mode none », « --no-mmap »…), +// ou vide. mmap (et auto) laisse le système relire les pages au besoin : lent +// quand la RAM manque, jamais fatal. none et dio copient les poids en mémoire +// anonyme, mlock et mmap+mlock les verrouillent. Lu sur la ligne finale +// (normalizeLoadFlags a déjà traduit l'une ou l'autre forme), puis dans les +// LLAMA_ARG_* si elle se tait. +func residentLoadMode(args []string, argEnv map[string]string) string { + mode := strings.ToLower(strings.TrimSpace(flagValue(args, "--load-mode", "-lm"))) + mlock, nommap := hasAnyFlag(args, "--mlock"), hasAnyFlag(args, "--no-mmap") + if mode == "" && !mlock && !nommap { + mode = strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_LOAD_MODE"])) + if mode == "" { + mlock, nommap = envTruthy(argEnv["LLAMA_ARG_MLOCK"]), envTruthy(argEnv["LLAMA_ARG_NO_MMAP"]) + } + } + switch mode { + case "none", "mlock", "dio", "mmap+mlock": + return "--load-mode " + mode + } + switch { + case mlock && nommap: + return "--mlock --no-mmap" + case mlock: + return "--mlock" + case nommap: + return "--no-mmap" + } + return "" +} + +// loadGuardOff : LOAD_GUARD=off, la clé d'échappement du refus. +func loadGuardOff(cfg map[string]string) bool { + switch strings.ToLower(strings.TrimSpace(cfg["LOAD_GUARD"])) { + case "off", "0", "false", "non", "no": + return true + } + return false +} + +// loadModeRisk : un mode de chargement résident avec trop de poids en RAM. +// Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé). +// +// llama.cpp #26110 : un modèle de 65 Go sur 60 Go de RAM, --load-mode none → +// swap, décodage de 25 à 7,5 t/s ; mlock → processus tué faute de mémoire. +// Seuls comptent les poids qui RESTENT en RAM : ceux qui partent en VRAM n'y +// séjournent pas. On en connaît une borne basse — modèle moins VRAM totale +// (tout le modèle sur macOS, mémoire unifiée) — et une estimation haute quand +// la VRAM est inconnue (le modèle entier). +// +// - refuse : la borne basse dépasse 90 % de la RAM effective (limite du +// conteneur comprise) — l'échec est certain. LOAD_GUARD=off lève le refus ; +// - warn : l'estimation dépasse 80 % — on prévient, on lance. +// +// Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable : +// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. +func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) { + if isExternalConfig(cfg) { + return "", "" + } + mode := residentLoadMode(args, si.ArgEnv) + modelMiB := si.ModelBytes >> 20 + if mode == "" || modelMiB <= 0 || si.RAMMiB <= 0 { + return "", "" + } + low, est, upTo := int64(0), modelMiB, true + switch { + case si.UnifiedMem: + low, est, upTo = modelMiB, modelMiB, false + case si.VRAMMiB > 0: + low = max(modelMiB-si.VRAMMiB, 0) + est, upTo = low, false + } + ram := si.RAMMiB + what := "environ " + if upTo { + what = "jusqu'à " + } + if low*10 > ram*9 { + msg := fmt.Sprintf("%s : au moins %s de poids restent en RAM (modèle %s) pour %s de RAM, limite du conteneur "+ + "comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). "+ + "--load-mode mmap laisse le système relire les pages au besoin", mode, gibText(low), gibText(modelMiB), gibText(ram)) + if loadGuardOff(cfg) { + return msg + " ; LOAD_GUARD=off : lancé quand même", "" + } + return "", msg + " ; LOAD_GUARD=off pour lancer quand même" + } + if est*10 > ram*8 { + return fmt.Sprintf("%s : %s%s de poids en RAM pour %s — au-delà, swap (décodage de 25 à 7,5 t/s dans "+ + "llama.cpp #26110) ou processus tué ; --load-mode mmap est plus sûr", mode, what, gibText(est), gibText(ram)), "" + } + return "", "" +} + +// previewServeHelp : l'aide supposée d'un moteur récent, pour les aperçus de +// l'éditeur (le process web ne lance pas le moteur pour lire son aide). Le +// lancement, lui, décide sur la vraie aide. +const previewServeHelp = "--load-mode --fit [on|off] --fit-target" + +// moePreview : les avis de moeNotes et loadModeRisk pour l'éditeur, calculés +// comme au lancement, sur un moteur supposé récent. +func moePreview(content string) (notes []string, load string) { + cfg := parseEnv(content) + if isExternalConfig(cfg) { + return nil, "" + } + si := serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}} + for _, k := range append(append(append([]string{}, serveArgEnv...), fitArgEnv...), loadArgEnv...) { + if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" { + si.ArgEnv[k] = v + } + } + if m := strings.TrimSpace(cfg["MODEL"]); m != "" { + if p, err := resolveServeModelPath(m); err == nil { + si.Model = p + } + } + if si.Model == "" { + return nil, "" + } + extra := splitArgs(cfg["EXTRA_ARGS"]) + probeCacheRAM(cfg, extra, &si) + norm, _ := normalizeLoadFlags(extra, true) + warn, refuse := loadModeRisk(cfg, norm, si) + if refuse != "" { + warn = "refusé au lancement — " + refuse + } + return moeNotes(cfg, extra, si), warn +} + +// autoPlacementPreset prépare la COPIE d'un preset MoE placé à la main, en +// placement automatique. Fonction pure : elle rend le nouveau contenu et la +// liste de ce qui change ; l'appelant l'enregistre sous un nouveau nom, et le +// preset d'origine reste tel quel — le repli si --fit échoue. +// +// Dans la copie : +// +// - retirés d'EXTRA_ARGS : les -ot qui visent les experts, un -ot qui +// n'envoie au CPU que per_layer_token_embd (couche d'entrée : llama.cpp la +// laisse toujours au CPU, le motif est redondant mais coupe --fit), +// --n-cpu-moe, --cpu-moe, -ngl, --tensor-split, --fit off, -b/-ub et les +// drapeaux de chargement ; +// - NGL retiré (Loki écrit -ngl auto sur un moteur qui sait placer), +// UBATCH=2048, BATCH=4096, « --load-mode mmap » (rien sur un moteur +// ancien, qui charge déjà ainsi) ; +// - CTX, cache KV, échantillonnage, raisonnement : intouchés. +// +// Refus (err) quand la copie ne tournerait pas en --fit — un autre -ot, -sm +// row/tensor, SPLIT_MODE=tensor, --n-cpu-ffn — ou quand CTX vaut 0 (--fit +// pourrait réduire le contexte), et quand il n'y a rien à migrer. Jamais de +// --tensor-read-lazy ni --lazy-mode : leur nom change d'un moteur à l'autre. +func autoPlacementPreset(content string) (out string, changes []string, err error) { + cfg := parseEnv(content) + if isExternalConfig(cfg) { + return "", nil, fmt.Errorf("preset externe : le modèle tourne ailleurs") + } + extra := splitArgs(cfg["EXTRA_ARGS"]) + var kept []string + migrated := false + drop := func(s string) { changes = append(changes, "retiré : "+s) } + for i := 0; i < len(extra); i++ { + a := extra[i] + name, val, hasEq := strings.Cut(a, "=") + value := func() string { + if hasEq { + return val + } + if i+1 < len(extra) { + i++ + return extra[i] + } + return "" + } + switch name { + case "-ot", "--override-tensor": + v := value() + var rest []string + for _, el := range strings.Split(v, ",") { + el = strings.TrimSpace(el) + pat, _, _ := strings.Cut(el, "=") + switch { + case el == "": + case otTargetsExperts(pat): + drop(name + " " + el + " (experts : --fit les place)") + migrated = true + case strings.Contains(strings.ToLower(pat), "per_layer_token_embd") && otOnCPU(el): + drop(name + " " + el + " (couche d'entrée, toujours au CPU)") + default: + rest = append(rest, el) + } + } + if len(rest) > 0 { + kept = append(kept, name, strings.Join(rest, ",")) + } + case "-ncmoe", "--n-cpu-moe": + v := value() + drop(name + " " + v) + if strings.TrimSpace(v) != "0" { + migrated = true + } + case "-cmoe", "--cpu-moe": + drop(name) + migrated = true + case "-ngl", "--n-gpu-layers", "--gpu-layers", "-ts", "--tensor-split", + "-b", "--batch-size", "-ub", "--ubatch-size", "--load-mode", "-lm": + drop(name + " " + value()) + case "--mlock", "--no-mmap", "--mmap": + drop(name) + case "-fit", "--fit": + v := value() + switch strings.ToLower(strings.TrimSpace(v)) { + case "off", "disabled", "false", "0": + drop(name + " " + v) + default: + kept = append(kept, name, v) + } + default: + kept = append(kept, a) + } + } + if !migrated { + return "", nil, fmt.Errorf("rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)") + } + kept = append(kept, "--load-mode", "mmap") + nc := map[string]string{} + for k, v := range cfg { + nc[k] = v + } + nc["EXTRA_ARGS"] = joinArgs(kept) + nc["UBATCH"], nc["BATCH"] = "2048", "4096" + delete(nc, "NGL") + if ctx, ok := ctxFixed(nc, kept); !ok { + return "", nil, fmt.Errorf("contexte « %s » non chiffré : --fit pourrait le réduire pour tenir — donne un CTX chiffré d'abord", ctx) + } + if splitModeKey(nc) == "tensor" { + return "", nil, fmt.Errorf("SPLIT_MODE=tensor coupe --fit : retire-le d'abord") + } + if why := fitBlocker(nc, kept, serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}}); why != "" { + return "", nil, fmt.Errorf("--fit resterait inactif (%s) : retire-le à la main d'abord", why) + } + if ngl := strings.TrimSpace(cfg["NGL"]); ngl != "" { + changes = append(changes, "NGL="+ngl+" retiré : le moteur place les couches (-ngl auto)") + } + changes = append(changes, "UBATCH=2048, BATCH=4096 (avant : "+firstNonEmpty(cfg["UBATCH"], "512")+" / "+ + firstNonEmpty(cfg["BATCH"], "2048")+")", "--load-mode mmap") + ctx, _ := ctxFixed(nc, kept) + changes = append(changes, "CTX conservé : "+ctx) + if k, v := serveKVTypes(nc); k != "" || v != "" || + flagValue(kept, "-ctk", "--cache-type-k") != "" || flagValue(kept, "-ctv", "--cache-type-v") != "" { + changes = append(changes, "cache KV conservé tel quel — garde-le identique pour comparer les deux presets") + } + out = content + out = presetSetKey(out, "EXTRA_ARGS", nc["EXTRA_ARGS"]) + out = presetSetKey(out, "UBATCH", "2048") + out = presetSetKey(out, "BATCH", "4096") + out = presetSetKey(out, "NGL", "") + return out, changes, nil +} + +// joinArgs : l'inverse de splitArgs — un argument qui contient un blanc est +// entouré d'apostrophes (de guillemets s'il en contient une), pour être relu +// d'un seul tenant. +func joinArgs(args []string) string { + parts := make([]string, len(args)) + for i, a := range args { + switch { + case a == "": + parts[i] = `""` + case strings.ContainsAny(a, " \t"): + if strings.Contains(a, "'") { + parts[i] = `"` + a + `"` + } else { + parts[i] = "'" + a + "'" + } + default: + parts[i] = a + } + } + return strings.Join(parts, " ") +} + +// presetSetKey remplace les lignes KEY=… d'un preset (toutes : parseEnv +// retient la dernière, une ancienne restée plus bas l'emporterait), l'ajoute à +// la fin si elle manque, les retire si val est vide. Le reste du fichier — +// commentaires, ordre, NAME — ne bouge pas : c'est l'équivalent de cfgWriteKey +// côté éditeur. +func presetSetKey(content, key, val string) string { + re := regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?` + regexp.QuoteMeta(key) + `[ \t]*=.*$`) + if val == "" { + return regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?`+regexp.QuoteMeta(key)+`[ \t]*=.*(?:\n|$)`). + ReplaceAllLiteralString(content, "") + } + line := key + "=" + quoteValue(val) + if re.MatchString(content) { + return re.ReplaceAllLiteralString(content, line) + } + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + return content + line + "\n" +} diff --git a/internal/loki/backend_serve_moe_test.go b/internal/loki/backend_serve_moe_test.go new file mode 100644 index 0000000..f351df7 --- /dev/null +++ b/internal/loki/backend_serve_moe_test.go @@ -0,0 +1,260 @@ +package loki + +import ( + "reflect" + "strings" + "testing" +) + +const gib = int64(1) << 30 + +// Un -ot ne compte comme experts en RAM que s'il vise les experts ET la RAM ; +// celui de la couche d'entrée (per_layer_token_embd) n'en est pas. +func TestCpuExperts(t *testing.T) { + cases := []struct { + extra string + env map[string]string + want bool + }{ + {`-ot per_layer_token_embd.weight=CPU`, nil, false}, + {`-ot "blk\..*\.ffn_.*_exps\.=CPU"`, nil, true}, + {`--override-tensor=per_layer_token_embd.weight=CPU,\.ffn_(up|down)_exps=CPU`, nil, true}, + {`-ot \.ffn_.*_exps=CUDA1`, nil, false}, + {`-cmoe`, nil, true}, + {`--n-cpu-moe 0`, nil, false}, + {`--n-cpu-moe 40`, nil, true}, + {`-ncmoe=12`, nil, true}, + {``, map[string]string{"LLAMA_ARG_N_CPU_MOE": "20"}, true}, + {``, map[string]string{"LLAMA_ARG_CPU_MOE": "1"}, true}, + {`-nkvo -ngl 20`, nil, false}, + } + for _, c := range cases { + if got := cpuExperts(splitArgs(c.extra), c.env) != ""; got != c.want { + t.Errorf("cpuExperts(%q, %v) = %v, want %v", c.extra, c.env, got, c.want) + } + } +} + +// moeNotes : les avis, au bon endroit et seulement là. +func TestMoeNotes(t *testing.T) { + moe := &GGUFInfo{ExpertCount: 128, BlockCount: 48} + big := serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, GPUs: 2} + cases := []struct { + name string + cfg map[string]string + si serveSysInfo + want string // sous-chaîne attendue ; vide = aucune note + }{ + {name: "modèle dense : rien", + cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"}, + si: serveSysInfo{Help: helpFit, GGUF: &GGUFInfo{BlockCount: 64}, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}}, + {name: "placement manuel, UBATCH 512 : ne pas monter UBATCH seul", + cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"}, si: big, want: "monte --n-cpu-moe d'abord"}, + {name: "placement manuel, UBATCH 2048 : --fit ne les place pas, sans conseil de lot", + cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40", "UBATCH": "2048"}, si: big, want: "--fit ne les place pas"}, + {name: "placement auto, MoE plus gros que la VRAM, UBATCH 512 : UBATCH 2048+", + cfg: map[string]string{}, si: big, want: "UBATCH 2048+ recommandé"}, + {name: "placement auto, UBATCH 4096 : rien", + cfg: map[string]string{"UBATCH": "4096", "BATCH": "4096"}, si: big}, + {name: "-ub dans EXTRA_ARGS l'emporte sur UBATCH", + cfg: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-ub 2048"}, si: big}, + {name: "le modèle tient en VRAM : rien", + cfg: map[string]string{}, + si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 20 * gib, VRAMMiB: 28 << 10, GPUs: 2}}, + {name: "moteur sans --fit : garder --n-cpu-moe", + cfg: map[string]string{}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}, + want: "garde --n-cpu-moe"}, + {name: "moteur sans --fit, NGL partiel : l'utilisateur a tranché", + cfg: map[string]string{"NGL": "20"}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}}, + {name: "aide illisible : rien", + cfg: map[string]string{}, si: serveSysInfo{GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}}, + {name: "-ot de la seule couche d'entrée : --fit coupé, experts tous sur GPU", + cfg: map[string]string{"EXTRA_ARGS": "-ot per_layer_token_embd.weight=CPU"}, si: big, + want: "-ot qui ne vise pas les experts"}, + {name: "VRAM inconnue : rien", + cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib}}, + {name: "macOS, mémoire unifiée : rien", + cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, UnifiedMem: true}}, + {name: "preset externe : rien", + cfg: map[string]string{extKeyFlag: "1", extKeyURL: "https://api.example.com/v1", "EXTRA_ARGS": "--n-cpu-moe 40"}, si: big}, + } + for _, c := range cases { + notes := moeNotes(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si) + switch { + case c.want == "" && len(notes) != 0: + t.Errorf("%s : notes inattendues %q", c.name, notes) + case c.want != "" && (len(notes) != 1 || !strings.Contains(notes[0], c.want)): + t.Errorf("%s : notes %q, attendu %q", c.name, notes, c.want) + } + } + // Placement manuel en UBATCH 2048 : pas de conseil de lot. + n := moeNotes(map[string]string{"UBATCH": "2048"}, splitArgs("--n-cpu-moe 40"), big) + if len(n) != 1 || strings.Contains(n[0], "UBATCH") { + t.Errorf("conseil de lot en trop : %q", n) + } +} + +// loadModeRisk : un avertissement d'ordinaire, un refus seulement quand la +// borne basse des poids en RAM dépasse 90 % de la RAM effective. +func TestLoadModeRisk(t *testing.T) { + cases := []struct { + name string + cfg map[string]string + args string + si serveSysInfo + warn, refuse bool + }{ + {name: "mmap : rien", + args: "--load-mode mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}}, + {name: "sans drapeau : rien", + si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}}, + {name: "none, 66 Gio certains en RAM pour 60 : refus (#26110)", + args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true}, + {name: "même chose, LOAD_GUARD=off : avertissement seulement", + cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none", + si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true}, + {name: "mlock, 54 Gio en RAM pour 64 : avertissement", + args: "--load-mode mlock", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true}, + {name: "dense tout en VRAM sur une machine à peu de RAM : rien (pas de faux positif)", + args: "--load-mode none", si: serveSysInfo{ModelBytes: 16 * gib, VRAMMiB: 24 << 10, RAMMiB: 8 << 10}}, + {name: "VRAM inconnue : avertissement « jusqu'à », jamais de refus", + args: "--load-mode dio", si: serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}, warn: true}, + {name: "macOS, mémoire unifiée : tout le modèle compte", + args: "--load-mode mmap+mlock", si: serveSysInfo{ModelBytes: 60 * gib, RAMMiB: 64 << 10, UnifiedMem: true}, refuse: true}, + {name: "moteur ancien : --no-mmap", + args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true}, + {name: "moteur ancien : --mlock --no-mmap", + args: "--mlock --no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true}, + {name: "LLAMA_ARG_NO_MMAP dans l'environnement", + si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10, + ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}, refuse: true}, + {name: "limite du conteneur (RAM effective) : refus", + args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, VRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true}, + {name: "taille inconnue : rien", + args: "--load-mode none", si: serveSysInfo{VRAMMiB: 16 << 10, RAMMiB: 16 << 10}}, + } + for _, c := range cases { + cfg := c.cfg + if cfg == nil { + cfg = map[string]string{} + } + warn, refuse := loadModeRisk(cfg, splitArgs(c.args), c.si) + if (warn != "") != c.warn || (refuse != "") != c.refuse { + t.Errorf("%s : warn=%q refuse=%q", c.name, warn, refuse) + } + if refuse != "" && !strings.Contains(refuse, "LOAD_GUARD=off") { + t.Errorf("%s : refus sans clé d'échappement : %q", c.name, refuse) + } + } +} + +// Sans ces clés, la ligne et l'environnement ne bougent pas quand le modèle +// est un MoE : seules des notes s'ajoutent. +func TestBuildServeArgsMoeNotesOnly(t *testing.T) { + const bin, model = "/opt/llama/llama-server", "/models/Qwen-MoE-Q4.gguf" + cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--n-cpu-moe 40 --load-mode none"} + extra := splitArgs(cfg["EXTRA_ARGS"]) + plain := serveSysInfo{Help: helpFit, Model: model} + moe := plain + moe.GGUF, moe.ModelBytes, moe.VRAMMiB, moe.RAMMiB = &GGUFInfo{ExpertCount: 128}, 82*gib, 28<<10, 64<<10 + a1, e1, n1 := buildServeArgs(cfg, extra, bin, plain) + a2, e2, n2 := buildServeArgs(cfg, extra, bin, moe) + if !reflect.DeepEqual(a1, a2) || !reflect.DeepEqual(e1, e2) { + t.Fatalf("la ligne a bougé :\n%q\n%q\nenv %v / %v", a1, a2, e1, e2) + } + if len(n2) != len(n1)+2 { + t.Fatalf("notes attendues (placement manuel + chargement) : %q", n2) + } +} + +// La copie en placement auto du preset MoE de l'utilisateur. +func TestAutoPlacementPreset(t *testing.T) { + src := "# NAME=QWEN 3.6 MOE\n# experts à la main\nMODEL=Qwen-MoE-Q4.gguf\nCTX=65536\nNGL=999\nUBATCH=512\n" + + `EXTRA_ARGS="-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0 --cache-type-v q8_0 --jinja --mlock --chat-template-file '/mes modèles/tpl.jinja'"` + "\n" + out, changes, err := autoPlacementPreset(src) + if err != nil { + t.Fatal(err) + } + cfg := parseEnv(out) + wantExtra := []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--jinja", + "--chat-template-file", "/mes modèles/tpl.jinja", "--load-mode", "mmap"} + if got := splitArgs(cfg["EXTRA_ARGS"]); !reflect.DeepEqual(got, wantExtra) { + t.Errorf("EXTRA_ARGS = %q\nwant %q", got, wantExtra) + } + if cfg["UBATCH"] != "2048" || cfg["BATCH"] != "4096" || cfg["CTX"] != "65536" || cfg["MODEL"] != "Qwen-MoE-Q4.gguf" { + t.Errorf("clés : %v", cfg) + } + if _, ok := cfg["NGL"]; ok { + t.Errorf("NGL gardé : %v", cfg) + } + if !strings.Contains(out, "# experts à la main") || !strings.HasPrefix(out, "# NAME=QWEN 3.6 MOE") { + t.Errorf("commentaires perdus :\n%s", out) + } + if len(changes) == 0 || !strings.Contains(strings.Join(changes, "\n"), "cache KV conservé") { + t.Errorf("changements : %q", changes) + } + // L'original n'est pas touché (chaîne passée par valeur, mais on vérifie la + // fonction : elle ne rend rien d'autre que la copie). + if parseEnv(src)["UBATCH"] != "512" { + t.Error("source modifiée") + } + + refus := []struct{ name, content string }{ + {"rien à migrer", "CTX=65536\nEXTRA_ARGS=--jinja\n"}, + {"CTX=0", "CTX=0\nEXTRA_ARGS=--n-cpu-moe 40\n"}, + {"-ot hors experts restant", `CTX=65536` + "\n" + `EXTRA_ARGS="-ot blk\.0\.attn.*=CUDA0,\.ffn_.*_exps=CPU"` + "\n"}, + {"-sm row", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 -sm row\"\n"}, + {"SPLIT_MODE=tensor", "CTX=65536\nSPLIT_MODE=tensor\nEXTRA_ARGS=--n-cpu-moe 40\n"}, + {"--n-cpu-ffn", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 --n-cpu-ffn 4\"\n"}, + {"preset externe", extKeyFlag + "=1\n" + extKeyURL + "=https://api.example.com/v1\nEXTRA_ARGS=--n-cpu-moe 40\n"}, + } + for _, r := range refus { + if _, _, err := autoPlacementPreset(r.content); err == nil { + t.Errorf("%s : accepté", r.name) + } + } + // --cpu-moe et -ngl / --tensor-split / --fit off retirés ensemble. + out, _, err = autoPlacementPreset("CTX=32768\nEXTRA_ARGS=\"-cmoe -ngl 99 -ts 1,1 --fit off -ub 512\"\n") + if err != nil { + t.Fatal(err) + } + if got := parseEnv(out)["EXTRA_ARGS"]; got != "--load-mode mmap" { + t.Errorf("EXTRA_ARGS = %q", got) + } +} + +// presetSetKey : remplace, ajoute, retire — sans toucher aux voisines (BATCH +// n'est pas UBATCH) ni aux commentaires. +func TestPresetSetKey(t *testing.T) { + src := "# BATCH=1\nUBATCH=512\nBATCH=2048\n" + if got := presetSetKey(src, "BATCH", "4096"); got != "# BATCH=1\nUBATCH=512\nBATCH=4096\n" { + t.Errorf("remplacement : %q", got) + } + if got := presetSetKey(src, "NGL", "auto"); got != src+"NGL=auto\n" { + t.Errorf("ajout : %q", got) + } + if got := presetSetKey("A=1", "B", "2"); got != "A=1\nB=2\n" { + t.Errorf("ajout sans fin de ligne : %q", got) + } + if got := presetSetKey(src, "UBATCH", ""); got != "# BATCH=1\nBATCH=2048\n" { + t.Errorf("retrait : %q", got) + } + if got := presetSetKey("X=\"a b\"\n", "X", "c d"); parseEnv(got)["X"] != "c d" { + t.Errorf("guillemets : %q", got) + } + // Clé en double : parseEnv retient la dernière, toutes sont réécrites. + if got := presetSetKey("NGL=999\nCTX=1\nexport NGL=40\n", "NGL", "auto"); parseEnv(got)["NGL"] != "auto" { + t.Errorf("doublon : %q", got) + } + if got := presetSetKey("NGL=999\nCTX=1\nNGL=40", "NGL", ""); got != "CTX=1\n" { + t.Errorf("retrait des doublons : %q", got) + } +} + +// joinArgs relu par splitArgs rend les mêmes arguments. +func TestJoinArgsRoundTrip(t *testing.T) { + args := []string{"--jinja", "--chat-template-file", "/mes modèles/tpl.jinja", "-ot", `blk\.(1|2)\.ffn=CPU`, "", "it's here"} + if got := splitArgs(joinArgs(args)); !reflect.DeepEqual(got, args) { + t.Errorf("aller-retour : %q", got) + } +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index d02e168..4da922c 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -74,6 +74,9 @@ var configTemplate = []struct{ key, help string }{ {"THREADS_BATCH", "threads CPU du prefill et de la vérification spéculative (MTP) ; vide ou 0 = comme THREADS"}, {"FIT_TARGET", "Mio laissés libres par carte par le placement auto (--fit-target), ex. 1024,3072 dans l'ordre des cartes ; " + "vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"}, + {"LOAD_GUARD", "off = lancer quand même un --load-mode none/mlock/dio/mmap+mlock (ou --no-mmap, --mlock) dont les poids " + + "restés en RAM dépassent à coup sûr 90 % de la RAM (limite du conteneur comprise) ; vide (défaut) = refus clair au " + + "lancement dans ce cas, simple avertissement au-delà de 80 % estimés"}, {"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " + "vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"}, {"SPLIT_MODE", "tensor = parallélisme de tenseurs entre cartes CUDA (-sm tensor, expérimental) : décodage plus rapide, " + diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 15a736b..72d984a 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3451,7 +3451,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Experts MoE sur CPUvide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent) - + +
+ +
Threads CPUvide ou 0 = cœurs physiques @@ -3467,7 +3474,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
UBATCH - +
Chargement - @@ -3564,6 +3571,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+ +
@@ -5992,6 +6004,7 @@ function populateSettings(){ set('s-ubatch', cfgReadKey('UBATCH')); set('s-cram', cfgReadKey('CACHE_RAM')); refreshCacheRAM(); + refreshMoe(); set('s-tbatch', cfgReadKey('THREADS_BATCH')); set('s-kv', cfgReadKey('KV_TYPE')); syncKVSub(); @@ -6089,6 +6102,55 @@ async function refreshCacheRAM(){ if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' + (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur') + side; } +// Avis MoE et mode de chargement, calculés côté serveur comme au lancement +// (backend_serve_moe.go) : experts placés à la main, UBATCH conseillé, moteur +// sans --fit, chargement résident trop gros pour la RAM. Des avis seulement : +// rien n'est écrit dans le preset. Une demande en vol ; la dernière gagne. +let moeSeq = 0, moeTimer = null, moeChanges = null; +function refreshMoeSoon(){ clearTimeout(moeTimer); moeTimer = setTimeout(refreshMoe, 400); } +async function refreshMoe(){ + const row = document.getElementById('m-moe-row'), note = document.getElementById('m-moe-note'); + const dup = document.getElementById('m-moe-dup'); + const lrow = document.getElementById('s-loadmode-warn-row'), lw = document.getElementById('s-loadmode-warn'); + if(!row) return; + const seq = ++moeSeq; + let r = {}; + try{ r = await jpost('/api/preset/moe', {content: document.getElementById('m-content').value}); } + catch(_){ r = {}; } + if(seq !== moeSeq) return; + const ap = r.ok ? r.autoplace : null; + const parts = (r.ok && r.notes) ? r.notes.slice() : []; + if(ap && !ap.ok) parts.push('placement auto impossible : ' + ap.why); + note.textContent = parts.join(' · '); + moeChanges = (ap && ap.ok) ? (ap.changes || []) : null; + dup.hidden = !moeChanges; + row.style.display = (parts.length || moeChanges) ? '' : 'none'; + if(lrow && lw){ + lw.textContent = (r.ok && r.load) || ''; + lrow.style.display = lw.textContent ? '' : 'none'; + } +} +// « Dupliquer en placement auto » : une COPIE du preset tel qu'affiché, sans +// les experts placés à la main (--fit les répartit carte par carte), UBATCH +// 2048 / BATCH 4096, NGL auto, chargement mmap, CTX et cache KV intacts. Le +// preset d'origine ne bouge pas : c'est le repli si --fit échoue. +async function dupAutoPlacement(){ + if(!moeChanges) return; + const msg = 'Créer une COPIE de ce preset en placement automatique (--fit place les experts carte par carte) :\n\n' + + moeChanges.map(c => '• ' + c).join('\n') + + '\n\nLe preset d\'origine reste tel quel : c\'est le repli. Après la bascule sur la copie, le journal doit dire ' + + '« successfully fit params » — « failed to fit » met tous les experts sur GPU : revenir à l\'original. ' + + 'Puis comparer les deux au « bench complet ».'; + if(!await askConfirm(msg, {title:'Placement auto', okText:'Créer la copie'})) return; + let r = {}; + try{ + r = await jpost('/api/preset/autoplace', {id: editingKey || '', name: document.getElementById('m-name').value.trim(), + content: document.getElementById('m-content').value}); + }catch(e){ r = {ok:false, error:e.message}; } + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('copie créée : ' + r.name); + KINDS.preset.reload(); +} function syncKVSub(){ const el = document.getElementById('s-kv-sub'); if(!el) return; diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 329818d..f595a12 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -1225,7 +1225,14 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Experts MoE sur CPUvide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent) - + +
+ +
Threads CPUvide ou 0 = cœurs physiques @@ -1241,7 +1248,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
UBATCH - +
Chargement - @@ -1338,6 +1345,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+ +
diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index ae31113..c5f78e3 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -1018,6 +1018,7 @@ function populateSettings(){ set('s-ubatch', cfgReadKey('UBATCH')); set('s-cram', cfgReadKey('CACHE_RAM')); refreshCacheRAM(); + refreshMoe(); set('s-tbatch', cfgReadKey('THREADS_BATCH')); set('s-kv', cfgReadKey('KV_TYPE')); syncKVSub(); @@ -1115,6 +1116,55 @@ async function refreshCacheRAM(){ if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' + (r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur') + side; } +// Avis MoE et mode de chargement, calculés côté serveur comme au lancement +// (backend_serve_moe.go) : experts placés à la main, UBATCH conseillé, moteur +// sans --fit, chargement résident trop gros pour la RAM. Des avis seulement : +// rien n'est écrit dans le preset. Une demande en vol ; la dernière gagne. +let moeSeq = 0, moeTimer = null, moeChanges = null; +function refreshMoeSoon(){ clearTimeout(moeTimer); moeTimer = setTimeout(refreshMoe, 400); } +async function refreshMoe(){ + const row = document.getElementById('m-moe-row'), note = document.getElementById('m-moe-note'); + const dup = document.getElementById('m-moe-dup'); + const lrow = document.getElementById('s-loadmode-warn-row'), lw = document.getElementById('s-loadmode-warn'); + if(!row) return; + const seq = ++moeSeq; + let r = {}; + try{ r = await jpost('/api/preset/moe', {content: document.getElementById('m-content').value}); } + catch(_){ r = {}; } + if(seq !== moeSeq) return; + const ap = r.ok ? r.autoplace : null; + const parts = (r.ok && r.notes) ? r.notes.slice() : []; + if(ap && !ap.ok) parts.push('placement auto impossible : ' + ap.why); + note.textContent = parts.join(' · '); + moeChanges = (ap && ap.ok) ? (ap.changes || []) : null; + dup.hidden = !moeChanges; + row.style.display = (parts.length || moeChanges) ? '' : 'none'; + if(lrow && lw){ + lw.textContent = (r.ok && r.load) || ''; + lrow.style.display = lw.textContent ? '' : 'none'; + } +} +// « Dupliquer en placement auto » : une COPIE du preset tel qu'affiché, sans +// les experts placés à la main (--fit les répartit carte par carte), UBATCH +// 2048 / BATCH 4096, NGL auto, chargement mmap, CTX et cache KV intacts. Le +// preset d'origine ne bouge pas : c'est le repli si --fit échoue. +async function dupAutoPlacement(){ + if(!moeChanges) return; + const msg = 'Créer une COPIE de ce preset en placement automatique (--fit place les experts carte par carte) :\n\n' + + moeChanges.map(c => '• ' + c).join('\n') + + '\n\nLe preset d\'origine reste tel quel : c\'est le repli. Après la bascule sur la copie, le journal doit dire ' + + '« successfully fit params » — « failed to fit » met tous les experts sur GPU : revenir à l\'original. ' + + 'Puis comparer les deux au « bench complet ».'; + if(!await askConfirm(msg, {title:'Placement auto', okText:'Créer la copie'})) return; + let r = {}; + try{ + r = await jpost('/api/preset/autoplace', {id: editingKey || '', name: document.getElementById('m-name').value.trim(), + content: document.getElementById('m-content').value}); + }catch(e){ r = {ok:false, error:e.message}; } + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('copie créée : ' + r.name); + KINDS.preset.reload(); +} function syncKVSub(){ const el = document.getElementById('s-kv-sub'); if(!el) return; diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 001e413..e24209e 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -508,6 +508,62 @@ func handlePresetCacheRAM(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, out) } +// handlePresetMoE : avis MoE et mode de chargement pour le preset en cours +// d'édition (voir backend_serve_moe.go), et ce que ferait « Dupliquer en +// placement auto » — sans rien écrire. +func handlePresetMoE(w http.ResponseWriter, r *http.Request) { + var req struct { + Content string `json:"content"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + notes, load := moePreview(req.Content) + out := map[string]any{"ok": true, "notes": notes, "load": load} + // L'assistant n'est proposé qu'à un preset qui place des experts à la + // main ; « rien à migrer » ne s'affiche pas. + if cfg := parseEnv(req.Content); !isExternalConfig(cfg) && cpuExperts(splitArgs(cfg["EXTRA_ARGS"]), nil) != "" { + if _, changes, err := autoPlacementPreset(req.Content); err != nil { + out["autoplace"] = map[string]any{"ok": false, "why": err.Error()} + } else { + out["autoplace"] = map[string]any{"ok": true, "changes": changes} + } + } + sendJSON(w, 200, out) +} + +// handlePresetAutoPlace crée la copie en placement auto du preset édité (son +// contenu tel qu'affiché), sous un nouveau nom. Le preset d'origine n'est pas +// touché, et la copie n'est pas activée : c'est à l'utilisateur de basculer, +// puis de comparer (bench complet). +func handlePresetAutoPlace(w http.ResponseWriter, r *http.Request) { + if !postOnly(w, r) { + return + } + var req struct { + ID string `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + out, changes, err := autoPlacementPreset(req.Content) + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + name := firstNonEmpty(strings.TrimSpace(req.Name), presetDisplayName(req.Content, req.ID), "preset") + " (placement auto)" + newID, err := SavePreset("", name, out) + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": name, "changes": changes}) +} + func handlePresetSave(w http.ResponseWriter, r *http.Request) { var req presetSaveReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 38f9805..8ad4cbb 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -253,7 +253,9 @@ func newWebMux() *http.ServeMux { api("/api/presets", handlePresets) api("/api/preset", handlePreset) api("/api/preset/save", handlePresetSave) - api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité + api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité + api("/api/preset/moe", handlePresetMoE) // avis MoE / chargement pour le preset édité + api("/api/preset/autoplace", handlePresetAutoPlace) // copie du preset en placement auto (--fit) api("/api/preset/delete", handlePresetDelete) // Presets externes : le chat part vers une API OpenAI-compatible distante au // lieu du llama-server local (backend_external.go).