mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Sur plusieurs cartes, llama.cpp fait travailler les GPU en pipeline quand le modèle y tient en entier ; encore faut-il que le CPU empile assez de lancements CUDA d'avance. CUDA_SCALE_LAUNCH_QUEUES=4x agrandit cette file du pilote : mêmes noyaux, même ordre, sortie identique — seul le prompt peut gagner (+10-25 % en amont sur un 70B, non mesuré ici), le décodage ne bouge pas. llama.cpp l'a retiré de ses défauts après des blocages sur Jetson : Loki ne le pose donc que là où le pipeline peut réellement exister. - launchQueuesEnv (pure) : 4x seulement si ≥ 2 GPU CUDA servis (--device, sinon CUDA_VISIBLE_DEVICES, sinon nvidia-smi borné à 3 s) et aucun bloqueur : -ot, --cpu-moe, --n-cpu-moe ≠ 0, -nkvo, -sm autre que layer, NGL chiffré hors 999 — drapeaux d'EXTRA_ARGS ou LLAMA_ARG_* équivalents - une variable déjà dans l'environnement n'est jamais touchée ; CUDA_LAUNCH_QUEUES=off la coupe, 0.25x/0.5x/2x/4x l'imposent, toute autre valeur est ignorée en le disant - la valeur posée s'affiche sur la ligne « [loki serve] » ; la ligne de commande ne change pas, BATCH/UBATCH non plus - CUDA_LAUNCH_QUEUES survit aux bascules de preset (réglage machine qu'un preset peut imposer) - éditeur : « Experts MoE sur CPU » rappelle que ça coupe le pipeline sur 2 GPU Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
457 lines
16 KiB
Go
457 lines
16 KiB
Go
package loki
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// A preset's IDENTITY is its filename (ID, without the .env suffix), which is
|
|
// always unique. Its DISPLAY name lives in an optional `# NAME=` line inside the
|
|
// file, so several presets can share the same display name without overwriting
|
|
// each other (their filenames differ — see uniquePresetID).
|
|
type Preset struct {
|
|
ID string // filename without .env — stable, unique identity
|
|
Name string // display name (# NAME= line, falls back to ID)
|
|
Path string
|
|
Active bool
|
|
}
|
|
|
|
var nameLineRe = regexp.MustCompile(`(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$`)
|
|
|
|
// presetDisplayName extracts the `# NAME=` value from a preset body, falling
|
|
// back to `fallback` (the filename id) when absent — keeps old presets working.
|
|
func presetDisplayName(content, fallback string) string {
|
|
for _, line := range strings.Split(content, "\n") {
|
|
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
|
|
i := strings.IndexByte(s, '=')
|
|
if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") {
|
|
if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
// withDisplayName ensures the body carries a `# NAME=<name>` line (replacing an
|
|
// existing one, or prepended otherwise).
|
|
func withDisplayName(content, name string) string {
|
|
line := "# NAME=" + name
|
|
if nameLineRe.MatchString(content) {
|
|
return nameLineRe.ReplaceAllString(content, line)
|
|
}
|
|
return line + "\n" + content
|
|
}
|
|
|
|
// configFingerprint réduit une configuration à l'ENSEMBLE TRIÉ de ses
|
|
// affectations effectives, puis le hache. Les clés « appareil » (preservedKeys,
|
|
// réappliquées par SwitchToPreset) sont ignorées : sans ça, les MEM_MODE /
|
|
// CRAWL4AI_URL injectés rendraient la config active différente de TOUS les
|
|
// presets, et aucun ne serait jamais détecté comme actif.
|
|
func configFingerprint(m map[string]string) string {
|
|
pairs := make([]string, 0, len(m))
|
|
for k, v := range m {
|
|
if isPreservedKey(k) {
|
|
continue
|
|
}
|
|
pairs = append(pairs, k+"="+v)
|
|
}
|
|
sort.Strings(pairs)
|
|
h := sha1.Sum([]byte(strings.Join(pairs, "\n")))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
// presetFingerprint est configFingerprint appliqué au contenu d'un preset.
|
|
func presetFingerprint(content []byte) string {
|
|
return configFingerprint(parseEnv(string(content)))
|
|
}
|
|
|
|
// isPreservedKey reports whether key is one of the device-level preservedKeys.
|
|
func isPreservedKey(key string) bool {
|
|
for _, k := range preservedKeys {
|
|
if key == k {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ListPresets renvoie tous les presets/*.env, en marquant celui qui correspond
|
|
// à la configuration active (clés « appareil » ignorées), triés par nom.
|
|
func ListPresets() ([]Preset, error) {
|
|
dir := presetsDir()
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
cur := configFingerprint(ReadConfig())
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []Preset{}
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") {
|
|
continue
|
|
}
|
|
p := filepath.Join(dir, e.Name())
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
id := strings.TrimSuffix(e.Name(), ".env")
|
|
out = append(out, Preset{
|
|
ID: id,
|
|
Name: presetDisplayName(string(b), id),
|
|
Path: p,
|
|
Active: presetFingerprint(b) == cur,
|
|
})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out, nil
|
|
}
|
|
|
|
// uniquePresetID derives a unique filename id from a display name, appending
|
|
// " (2)", " (3)"… on collision so duplicate names never overwrite.
|
|
func uniquePresetID(name string) (string, error) {
|
|
base := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(name))
|
|
if base == "" {
|
|
base = "preset"
|
|
}
|
|
cand := base
|
|
for n := 2; n < 10000; n++ {
|
|
p, err := safePresetPath(cand)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := os.Stat(p); os.IsNotExist(err) {
|
|
return cand, nil
|
|
}
|
|
cand = fmt.Sprintf("%s (%d)", base, n)
|
|
}
|
|
return "", fmt.Errorf("impossible de générer un nom de fichier unique")
|
|
}
|
|
|
|
// validPresetName accepts any name (spaces, accents, parentheses…) as long as it
|
|
// stays a single safe filename: no path separators, no control chars, and not a
|
|
// reserved directory entry. Path containment is double-checked in safePresetPath.
|
|
func validPresetName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("nom vide")
|
|
}
|
|
if name == "." || name == ".." {
|
|
return fmt.Errorf("nom réservé")
|
|
}
|
|
if strings.ContainsAny(name, `/\`+"\x00") {
|
|
return fmt.Errorf(`le nom ne peut pas contenir / ni \`)
|
|
}
|
|
for _, r := range name {
|
|
if r < 0x20 {
|
|
return fmt.Errorf("le nom contient un caractère de contrôle invalide")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// safePresetPath validates name and returns its resolved path inside presetsDir.
|
|
func safePresetPath(name string) (string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if err := validPresetName(name); err != nil {
|
|
return "", err
|
|
}
|
|
root, err := filepath.Abs(presetsDir())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p := filepath.Join(root, name+".env")
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.HasPrefix(abs, root+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("path invalide")
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// preservedKeys sont des réglages « appareil » (préférences utilisateur, pas des
|
|
// paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça,
|
|
// écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait
|
|
// l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre.
|
|
// HOST y figure depuis la 0.8.4 : c'est un réglage de MACHINE (« le moteur
|
|
// est-il joignable depuis le réseau ? », voir sys_network.go), pas un réglage de
|
|
// modèle. Sans lui dans cette liste, basculer sur un preset écrit avant cette
|
|
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
|
|
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
|
|
// premier changement de preset.
|
|
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
|
|
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
|
|
// disparaître à la bascule suivante.
|
|
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"}
|
|
|
|
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
|
|
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
|
|
// choix de machine (`loki gpu`) qui doit survivre aux bascules, MAIS un preset
|
|
// a le droit de l'imposer — « QWEN 3.6 27B FABLE 2 GPU » réclame `1,0` pour que
|
|
// son `--tensor-split 0.965,0.035` ait bien deux cartes en face. Le préserver
|
|
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
|
|
// modèle se retrouvait entièrement sur une seule carte et mourait sur
|
|
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
|
|
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
|
|
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true}
|
|
|
|
// applyPresetFile installe le preset comme configuration active, en réinjectant
|
|
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
|
|
// testable sans redémarrer le service (donc sans lancer un vrai llama-server).
|
|
func applyPresetFile(target string) error {
|
|
src, err := os.ReadFile(target)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
next := parseEnv(string(src))
|
|
// Ré-applique les réglages appareil par-dessus le preset — sauf ceux que le
|
|
// preset revendique explicitement (softPreservedKeys).
|
|
cur := ReadConfig()
|
|
for _, k := range preservedKeys {
|
|
v, ok := cur[k]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if softPreservedKeys[k] {
|
|
if _, claimed := next[k]; claimed {
|
|
continue // le preset a son mot à dire sur cette clé : il gagne
|
|
}
|
|
}
|
|
next[k] = v
|
|
}
|
|
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
|
|
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
|
|
next["BIN"] = cur["BIN"]
|
|
}
|
|
return WriteConfig(next)
|
|
}
|
|
|
|
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
|
|
//
|
|
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
|
|
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
|
|
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
|
|
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
|
|
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
|
|
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
|
|
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
|
|
// preset : le courant reste.
|
|
//
|
|
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
|
|
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
|
|
// modèle : le preset gagne.
|
|
func presetImposesEngine(p string) bool {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" || engineOwns(p) || prebuiltOwns(p) {
|
|
return false
|
|
}
|
|
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// SwitchToPreset installe le preset et redémarre le service. Les réglages
|
|
// « appareil » (preservedKeys) sont conservés à travers la bascule.
|
|
func SwitchToPreset(target string) error {
|
|
if err := applyPresetFile(target); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
|
|
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
|
|
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
|
|
// le moteur encore en vie — le chat part vers l'API distante, garder le
|
|
// modèle en VRAM ne sert plus à rien.
|
|
if isExternalConfig(ReadConfig()) {
|
|
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
|
|
if !serviceIsActive() {
|
|
return nil
|
|
}
|
|
return serviceAction("stop")
|
|
}
|
|
fmt.Println(dim("[info] redémarrage du service..."))
|
|
return serviceAction("restart")
|
|
}
|
|
|
|
func cmdSwitch(args []string) error {
|
|
list, err := ListPresets()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(list) == 0 {
|
|
return fmt.Errorf("aucun preset dans %s", presetsDir())
|
|
}
|
|
fmt.Printf("\n %s (%s)\n\n", cyan("Presets disponibles"), presetsDir())
|
|
for i, p := range list {
|
|
mark := " "
|
|
if p.Active {
|
|
mark = green("●") + " actif"
|
|
}
|
|
fmt.Printf(" %2d) %-30s %s\n", i+1, p.Name, mark)
|
|
}
|
|
fmt.Println()
|
|
choice := ""
|
|
if len(args) > 0 {
|
|
choice = args[0]
|
|
} else {
|
|
fmt.Print("Numéro à activer (vide = annuler) : ")
|
|
sc := bufio.NewScanner(os.Stdin)
|
|
if sc.Scan() {
|
|
choice = strings.TrimSpace(sc.Text())
|
|
}
|
|
}
|
|
if choice == "" {
|
|
fmt.Println(dim("[info] annulé"))
|
|
return nil
|
|
}
|
|
n, err := strconv.Atoi(choice)
|
|
if err != nil || n < 1 || n > len(list) {
|
|
return fmt.Errorf("choix invalide")
|
|
}
|
|
return SwitchToPreset(list[n-1].Path)
|
|
}
|
|
|
|
// newPresetSeedKeys : les SEULES clés reprises de la configuration active quand
|
|
// on crée un preset. Ce sont des réglages de MACHINE (quel moteur, où il écoute),
|
|
// pas des réglages de modèle.
|
|
//
|
|
// Issue #17 : le nouveau preset repartait d'une copie COMPLÈTE de la config
|
|
// active. Les réglages du modèle précédent (EXTRA_ARGS — --n-cpu-moe,
|
|
// --tensor-split, --flash-attn… — mais aussi CTX, NGL, KV_TYPE, MODEL) se
|
|
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
|
|
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
|
|
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
|
|
//
|
|
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
|
|
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
|
|
// moteur mis à jour à la première bascule.
|
|
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
|
|
|
|
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
|
|
func newPresetSeed() map[string]string {
|
|
cur := ReadConfig()
|
|
seed := map[string]string{}
|
|
for _, k := range newPresetSeedKeys {
|
|
v := cur[k]
|
|
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
|
|
continue
|
|
}
|
|
seed[k] = v
|
|
}
|
|
return seed
|
|
}
|
|
|
|
// SavePreset writes a preset. When id == "" it creates a NEW preset under a
|
|
// freshly-generated unique filename (so duplicate display names never clash).
|
|
// When id != "" it updates that existing preset in place (filename unchanged —
|
|
// only the body and its `# NAME=` line change). Returns the resulting id.
|
|
func SavePreset(id, name, content string) (string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("nom requis")
|
|
}
|
|
content = withDisplayName(content, name)
|
|
if id == "" {
|
|
newID, err := uniquePresetID(name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
p, err := safePresetPath(newID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
|
return newID, os.WriteFile(p, []byte(content), 0o644)
|
|
}
|
|
p, err := safePresetPath(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
|
return id, os.WriteFile(p, []byte(content), 0o644)
|
|
}
|
|
|
|
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
|
|
// installe aussitôt la nouvelle version comme configuration active (mêmes
|
|
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
|
|
// l'appelant redémarre alors le moteur.
|
|
//
|
|
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
|
|
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
|
|
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
|
|
// preset n'était détecté actif (empreinte différente). Éditer le preset en
|
|
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
|
|
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
|
|
//
|
|
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
|
|
// porte l'empreinte de la configuration courante.
|
|
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
|
|
wasActive := false
|
|
if id != "" {
|
|
if old, rerr := ReadPreset(id); rerr == nil {
|
|
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
|
|
}
|
|
}
|
|
newID, err = SavePreset(id, name, content)
|
|
if err != nil || !wasActive {
|
|
return newID, false, err
|
|
}
|
|
p, err := safePresetPath(newID)
|
|
if err != nil {
|
|
return newID, false, err
|
|
}
|
|
if err := applyPresetFile(p); err != nil {
|
|
return newID, false, err
|
|
}
|
|
return newID, true, nil
|
|
}
|
|
|
|
// DeletePreset removes a preset by id; refuses if it is the active config.
|
|
func DeletePreset(id string) error {
|
|
p, err := safePresetPath(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return fmt.Errorf("introuvable")
|
|
}
|
|
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
|
|
return fmt.Errorf("preset actif, switche d'abord")
|
|
}
|
|
if err := os.Remove(p); err != nil {
|
|
return err
|
|
}
|
|
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
|
|
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
|
|
// sous le même id.
|
|
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
|
|
// Même ménage pour son benchmark : un preset recréé sous cet id en
|
|
// hériterait, alors qu'il a été mesuré sur un autre modèle.
|
|
deletePresetBench(id)
|
|
return nil
|
|
}
|
|
|
|
// ReadPreset returns the contents of a preset by id (filename).
|
|
func ReadPreset(id string) (string, error) {
|
|
p, err := safePresetPath(id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(b), nil
|
|
}
|