MoE : avis de placement, copie en placement auto et garde du mode de chargement

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 11:12:56 +02:00
1 parent 986a636c20
commit f87c7b615b
11 files changed
+1001 -8

No files matched your search

+14
View File
@@ -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
}
+2 -1
View File
@@ -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
+499
View File
@@ -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"
}
+260
View File
@@ -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)
}
}
+3
View File
@@ -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, " +
+65 -3
View File
@@ -3451,7 +3451,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value); refreshMoeSoon()"></span>
</div>
<!-- Avis MoE (refreshMoe) : experts placés à la main, UBATCH conseillé,
et l'assistant « Dupliquer en placement auto » — une COPIE du
preset, l'original reste tel quel. Masqué tant qu'il n'y a rien à dire. -->
<div class="pe-row stack" id="m-moe-row" style="display:none">
<div class="pe-note" id="m-moe-note"></div>
<button class="pe-link" id="m-moe-dup" onclick="dupAutoPlacement()" hidden>Dupliquer en placement auto…</button>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">vide ou 0 = cœurs physiques</span></span>
@@ -3467,7 +3474,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div class="pe-row">
<span class="pe-row-l">UBATCH</span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value); refreshMoeSoon()"></span>
</div>
<div class="pe-row">
<!-- Cache de prompts en RAM hôte (--cache-ram, Mio) : copie exacte de
@@ -3555,7 +3562,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
(normalizeLoadFlags). -->
<div class="pe-row">
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value); refreshMoeSoon()">
<option value="">auto</option>
<option value="mmap">mmap</option>
<option value="none">none</option>
@@ -3564,6 +3571,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="dio">dio</option>
</select></span></span>
</div>
<!-- Mode résident (none, mlock, dio, mmap+mlock) avec trop de poids en
RAM : l'avertissement du lancement (loadModeRisk), ou son refus. -->
<div class="pe-row stack" id="s-loadmode-warn-row" style="display:none">
<div class="pe-note" id="s-loadmode-warn" style="color:var(--warn)"></div>
</div>
</div>
</div>
@@ -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;
+15 -3
View File
@@ -1225,7 +1225,14 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value); refreshMoeSoon()"></span>
</div>
<!-- Avis MoE (refreshMoe) : experts placés à la main, UBATCH conseillé,
et l'assistant « Dupliquer en placement auto » — une COPIE du
preset, l'original reste tel quel. Masqué tant qu'il n'y a rien à dire. -->
<div class="pe-row stack" id="m-moe-row" style="display:none">
<div class="pe-note" id="m-moe-note"></div>
<button class="pe-link" id="m-moe-dup" onclick="dupAutoPlacement()" hidden>Dupliquer en placement auto…</button>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">vide ou 0 = cœurs physiques</span></span>
@@ -1241,7 +1248,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-row">
<span class="pe-row-l">UBATCH</span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value); refreshMoeSoon()"></span>
</div>
<div class="pe-row">
<!-- Cache de prompts en RAM hôte (--cache-ram, Mio) : copie exacte de
@@ -1329,7 +1336,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
(normalizeLoadFlags). -->
<div class="pe-row">
<span class="pe-row-l">Chargement<span class="pe-sub" id="s-loadmode-sub"></span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value)">
<span class="pe-row-c"><span class="pe-selc"><select id="s-loadmode" onchange="eaSetLoadMode(this.value); refreshMoeSoon()">
<option value="">auto</option>
<option value="mmap">mmap</option>
<option value="none">none</option>
@@ -1338,6 +1345,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="dio">dio</option>
</select></span></span>
</div>
<!-- Mode résident (none, mlock, dio, mmap+mlock) avec trop de poids en
RAM : l'avertissement du lancement (loadModeRisk), ou son refus. -->
<div class="pe-row stack" id="s-loadmode-warn-row" style="display:none">
<div class="pe-note" id="s-loadmode-warn" style="color:var(--warn)"></div>
</div>
</div>
</div>
+50
View File
@@ -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;
+56
View File
@@ -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 {
+3 -1
View File
@@ -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).