mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
L'avis rendu quand le refus attend les cartes du moteur (VRAM NVIDIA seule) annonçait « refus seulement si les cartes listées par le moteur le confirment » même avec LOAD_GUARD=off, où aucun refus n'est possible. Il retombe alors sur l'avis ordinaire. - Test : VRAM NVIDIA seule avec LOAD_GUARD=off, et aucun avis qui évoque un refus quand la clé d'échappement est posée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
629 lines
24 KiB
Go
629 lines
24 KiB
Go
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.
|
|
// LLAMA_ARG_RPC n'en est pas une, mais dit que des poids partent sur des
|
|
// machines distantes (loadModeRisk).
|
|
var loadArgEnv = []string{"LLAMA_ARG_LOAD_MODE", "LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_RPC"}
|
|
|
|
// 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 ""
|
|
}
|
|
|
|
// loadGuardMarker : la fin du message de refus, que l'interface reconnaît
|
|
// dans le journal du service (modelLoadError).
|
|
const loadGuardMarker = "LOAD_GUARD=off pour lancer quand même"
|
|
|
|
// 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
|
|
}
|
|
|
|
// loadGuardExitStatus : le code de sortie de « loki serve » quand LOAD_GUARD
|
|
// refuse. Le refus est certain et ne dépend que du preset et de la machine :
|
|
// relancer ne changerait rien. Sous systemd, Restart=on-failure relançait
|
|
// pourtant toutes les trois secondes, sans fin ; l'unité écrite par
|
|
// « loki install » porte donc RestartPreventExitStatus pour ce code-là
|
|
// (EX_CONFIG de sysexits.h : erreur de configuration). Le conteneur, lui, ne
|
|
// relance jamais le moteur de lui-même.
|
|
const loadGuardExitStatus = 78
|
|
|
|
// unitPreventsRefusalRestart : l'unité systemd (son texte) s'abstient-elle de
|
|
// relancer sur loadGuardExitStatus ? Une unité écrite par une version
|
|
// d'avant ne le fait pas.
|
|
func unitPreventsRefusalRestart(unit string) bool {
|
|
for _, line := range strings.Split(unit, "\n") {
|
|
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !ok || strings.TrimSpace(k) != "RestartPreventExitStatus" {
|
|
continue
|
|
}
|
|
for _, f := range strings.Fields(v) {
|
|
if f == strconv.Itoa(loadGuardExitStatus) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// loadGuardRefusal : l'erreur rendue par cmdServe pour un refus, avec son code
|
|
// de sortie. Un superviseur qui relancerait quand même sur ce code (unité
|
|
// systemd d'une version d'avant, launchd, qui relance toute sortie non nulle)
|
|
// reçoit 0 : une sortie « propre » n'est pas relancée, et la raison reste en
|
|
// clair dans le journal, où l'interface la reconnaît (modelLoadError). Le
|
|
// moteur d'essai de l'optimiseur garde le vrai code : c'est lui qui dit
|
|
// l'échec à « loki tune ».
|
|
func loadGuardRefusal(refuse string, trial bool) error {
|
|
code := loadGuardExitStatus
|
|
if !trial {
|
|
if restarts, hint := supervisorRestartsRefusal(); restarts {
|
|
code = 0
|
|
if hint != "" {
|
|
fmt.Fprintln(os.Stderr, "[loki serve] "+hint)
|
|
}
|
|
}
|
|
}
|
|
return &exitStatusError{code: code, err: fmt.Errorf("%s", refuse)}
|
|
}
|
|
|
|
// 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. Le code
|
|
// de sortie (loadGuardRefusal) empêche le superviseur de relancer en boucle.
|
|
func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) {
|
|
if isExternalConfig(cfg) {
|
|
return "", ""
|
|
}
|
|
// Un moteur qui connaît --load-mode n'a plus LLAMA_ARG_MLOCK ni
|
|
// LLAMA_ARG_NO_MMAP : restées dans l'environnement, elles n'ont aucun effet
|
|
// et ne doivent pas faire refuser un lancement en mmap.
|
|
env := si.ArgEnv
|
|
if helpSupportsLoadMode(si.Help) && (env["LLAMA_ARG_MLOCK"] != "" || env["LLAMA_ARG_NO_MMAP"] != "") {
|
|
env = map[string]string{"LLAMA_ARG_LOAD_MODE": si.ArgEnv["LLAMA_ARG_LOAD_MODE"]}
|
|
}
|
|
mode := residentLoadMode(args, env)
|
|
modelMiB := si.ModelBytes >> 20
|
|
if mode == "" || modelMiB <= 0 || si.RAMMiB <= 0 {
|
|
return "", ""
|
|
}
|
|
// Serveurs RPC : une part des poids part sur d'autres machines, la VRAM
|
|
// locale ne borne plus rien — inconnu, donc un avis au plus, jamais un refus.
|
|
remote := hasAnyFlag(args, "--rpc") || strings.TrimSpace(si.ArgEnv["LLAMA_ARG_RPC"]) != ""
|
|
low, est, upTo, unsure := int64(0), modelMiB, true, false
|
|
switch {
|
|
case remote:
|
|
case si.UnifiedMem:
|
|
low, est, upTo = modelMiB, modelMiB, false
|
|
case si.EngineVRAMMiB > 0:
|
|
// Les cartes que le moteur liste lui-même : rien au-delà ne peut
|
|
// recevoir de poids, la borne basse est sûre.
|
|
low = max(modelMiB-si.EngineVRAMMiB, 0)
|
|
est, upTo = low, false
|
|
case si.VRAMMiB > 0:
|
|
// nvidia-smi seulement : un moteur Vulkan sur cartes mixtes NVIDIA +
|
|
// AMD en utilise davantage, un moteur ROCm d'autres. Une estimation,
|
|
// jamais une borne : un avis au plus (voir probeLoadDevices).
|
|
est, upTo, unsure = max(modelMiB-si.VRAMMiB, 0), false, true
|
|
}
|
|
ram := si.RAMMiB
|
|
what := "environ "
|
|
if upTo {
|
|
what = "jusqu'à "
|
|
}
|
|
if unsure && est*10 > ram*9 && !loadGuardOff(cfg) {
|
|
return fmt.Sprintf("%s : environ %s de poids en RAM (modèle %s, VRAM NVIDIA %s) pour %s — échec probable ; "+
|
|
"refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; "+
|
|
"--load-mode mmap est plus sûr", mode, gibText(est), gibText(modelMiB), gibText(si.VRAMMiB), gibText(ram)), ""
|
|
}
|
|
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 + " ; " + loadGuardMarker
|
|
}
|
|
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 "", ""
|
|
}
|
|
|
|
// loadGuardNeedsDevices : un refus se profile sur la seule VRAM NVIDIA
|
|
// (nvidia-smi) ? Il ne sera prononcé que sur les cartes du moteur lui-même —
|
|
// à lire d'abord (probeLoadDevices). Fonction pure.
|
|
func loadGuardNeedsDevices(cfg map[string]string, args []string, si serveSysInfo) bool {
|
|
if si.EngineVRAMMiB > 0 || si.VRAMMiB <= 0 {
|
|
return false
|
|
}
|
|
si.EngineVRAMMiB = si.VRAMMiB
|
|
_, refuse := loadModeRisk(cfg, args, si)
|
|
return refuse != ""
|
|
}
|
|
|
|
// engineDevsVRAM : la VRAM des cartes listées par le moteur, toutes
|
|
// additionnées — sans --device, llama.cpp ne place rien ailleurs, et une carte
|
|
// qu'il listerait sans s'en servir (iGPU) ne fait que rendre le refus plus
|
|
// rare. 0 (inconnu) si la liste est vide ou si une carte annonce 0 Mio (déjà
|
|
// pleine : lecture transitoire, voir handleBackendDevices).
|
|
func engineDevsVRAM(devs []splitDev) int64 {
|
|
var total int64
|
|
for _, d := range devs {
|
|
if d.TotalMiB <= 0 {
|
|
return 0
|
|
}
|
|
total += d.TotalMiB
|
|
}
|
|
return total
|
|
}
|
|
|
|
// probeLoadDevices : seulement quand loadGuardNeedsDevices — un mode de
|
|
// chargement résident qu'on s'apprête à refuser. Les cartes de SPLIT_MODE si
|
|
// elles sont déjà lues, sinon --list-devices dans l'environnement du
|
|
// lancement (sélection GPU comprise). Échec de lecture : EngineVRAMMiB reste
|
|
// à 0, loadModeRisk avertit sans refuser. args : EXTRA_ARGS normalisé.
|
|
func probeLoadDevices(cfg map[string]string, args []string, bin string, si *serveSysInfo) {
|
|
if !loadGuardNeedsDevices(cfg, args, *si) {
|
|
return
|
|
}
|
|
devs := si.SplitDevs
|
|
if len(devs) == 0 {
|
|
devs = listEngineDevices(bin)
|
|
}
|
|
si.EngineVRAMMiB = engineDevsVRAM(devs)
|
|
}
|
|
|
|
// 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)
|
|
// Les cartes du moteur, sans le lancer : la dernière liste complète qu'il a
|
|
// donnée à l'éditeur (devices.json). Absente : un avis, pas un refus.
|
|
if loadGuardNeedsDevices(cfg, norm, si) {
|
|
bin := prebuiltResolveBin(firstNonEmpty(cfg["BIN"], ReadConfig()["BIN"]))
|
|
if list, ok := devPersistGet(bin + "\x00" + cfg["CUDA_VISIBLE_DEVICES"]); ok {
|
|
si.EngineVRAMMiB = engineDevsVRAM(splitDevsFrom(list))
|
|
}
|
|
}
|
|
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"
|
|
}
|