mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
En --parallel 1, une vérification, un sous-agent, une tâche ou un bench prennent le slot de la discussion : son état part dans le cache RAM et en revient, ou se recalcule s'il n'y tient plus. Avec SIDE_SLOT=on (off par défaut), le moteur ouvre deux slots et la discussion garde le sien. Dimensionnement choisi pour qu'un débordement concurrent soit impossible par construction : cache KV NON unifié (--no-kv-unified) et -c 2×CTX, soit deux flux séparés de CTX jetons. Sous cache unifié, les deux slots partagent le pool et, plein, llama.cpp renvoie « Context size has been exceeded. » aux deux — que Loki prenait pour un débordement de la conversation. Chaque slot garde la fenêtre entière : ctxWindow et la compaction restent sur CTX, rien n'est raccourci. --cache-idle-slots ne vide les slots au repos que sous cache unifié : rien à régler. - lancement : refus (un slot, ligne d'avant, note au journal) si modèle + deux états dépassent 90 % de la VRAM, VRAM ou GGUF inconnus, poids sur CPU, moteur sans --no-kv-unified, PARALLEL≠2, ou -c/-np/-kvu/--no-kv-unified/ --kv-unified-per-slot/--cache-idle-slots dans EXTRA_ARGS (ou leurs LLAMA_ARG_*) ; note de VRAM en plus, avertissement si NGL imposé - routage id_slot seulement si /props annonce 2 slots d'au moins CTX jetons : 0 pour le tour, ses étapes, le préchauffage et le résumé en continuation ; 1 pour vérification, sous-agents, tâches, bench, résumé sur transcription - clients /v1 (proxy et relais) : corps réécrit en id_slot 1, quel qu'il soit - cohérence lot 1 : l'effacement de slot s'abstient (2 slots) ; une requête du slot 1 n'avance pas le numéro d'engineSlotHolds, la continuation de compaction reste possible après une vérification - « Context size has been exceeded. » sans nombre de jetons, deux slots en service : requête rejouée telle quelle, jamais compaction ni réduction - préchauffage permis sur les deux slots de SIDE_SLOT, vers le slot 0 - éditeur de preset : VRAM du second slot ou raison du refus - tests : ligne identique sans la clé, chaque conflit refuse sans changer la ligne, routage par nature, aucun id_slot sur un slot / preset externe / fenêtre partagée, rejeu sans compaction, proxy forcé, préchauffage Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
8.0 KiB
Go
212 lines
8.0 KiB
Go
package loki
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// Second slot pour les travaux annexes (SIDE_SLOT, off par défaut).
|
||
//
|
||
// En --parallel 1, un vérificateur, un sous-agent, une tâche ou un bench
|
||
// prennent LE slot de la conversation : son état part dans le cache RAM et
|
||
// revient après — quand il y tient encore. Avec SIDE_SLOT=on, le moteur ouvre
|
||
// deux slots, la conversation garde le slot 0 et tout le reste passe par le
|
||
// slot 1 (id_slot, voir llm_sideslot.go) : l'état principal ne bouge plus du
|
||
// tout.
|
||
//
|
||
// Le dimensionnement est le cœur de l'affaire. Un cache KV UNIFIÉ partagé entre
|
||
// deux slots laisse chacun grandir jusqu'à la taille du pool : quand les deux
|
||
// travaillent et que le pool est plein, llama.cpp renvoie « Context size has
|
||
// been exceeded. » aux DEUX, et Loki prendrait ça pour un débordement de la
|
||
// conversation — compaction, voire réduction, d'un fil qui tenait très bien
|
||
// dans sa fenêtre. Et --kv-unified-per-slot plafonne tous les slots à la même
|
||
// valeur, sans rien garantir sur la somme. On prend donc le cache NON unifié
|
||
// (--no-kv-unified) avec -c 2×CTX : llama.cpp découpe alors le pool en deux
|
||
// flux de CTX jetons chacun, séparés. Un débordement concurrent est impossible
|
||
// par construction, chaque slot a exactement la fenêtre de la conversation (les
|
||
// travaux annexes ne sont pas raccourcis), et --cache-idle-slots ne vide plus
|
||
// les slots au repos (il ne le fait que sous cache unifié) : rien d'autre à
|
||
// régler.
|
||
//
|
||
// Le prix : le cache KV (et l'état récurrent d'un hybride) en double, en VRAM.
|
||
// On refuse donc l'option quand le compte ne tient pas, quand des poids vivent
|
||
// en RAM (le KV en plus pousserait le placement automatique à sortir des
|
||
// couches du GPU), et dès que l'utilisateur a réglé lui-même l'un des leviers
|
||
// en jeu : on ne mélange pas ses réglages et les nôtres.
|
||
|
||
// sideSlotOn : la clé SIDE_SLOT est-elle posée ?
|
||
func sideSlotOn(cfg map[string]string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(cfg["SIDE_SLOT"])) {
|
||
case "on", "1", "true", "yes", "oui":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// sideSlotConflictFlags : les drapeaux d'EXTRA_ARGS qui décident déjà de ce que
|
||
// SIDE_SLOT règle. Un seul suffit à refuser.
|
||
var sideSlotConflictFlags = []string{
|
||
"-c", "--ctx-size",
|
||
"-np", "--parallel",
|
||
"-kvu", "--kv-unified", "-no-kvu", "--no-kv-unified",
|
||
"--kv-unified-per-slot",
|
||
"--cache-idle-slots", "--no-cache-idle-slots",
|
||
}
|
||
|
||
// sideSlotArgEnv : les mêmes leviers posés par variable. La ligne de commande
|
||
// les écraserait sans rien dire : un choix de l'utilisateur, on s'abstient.
|
||
var sideSlotArgEnv = []string{"LLAMA_ARG_N_PARALLEL", "LLAMA_ARG_KV_UNIFIED", "LLAMA_ARG_KV_UNIFIED_PER_SLOT",
|
||
"LLAMA_ARG_CACHE_IDLE_SLOTS", "LLAMA_ARG_CTX_SIZE"}
|
||
|
||
// probeSideSlotEnv complète ArgEnv avec sideSlotArgEnv, seulement si la clé est
|
||
// posée : sans elle, rien n'est lu.
|
||
func probeSideSlotEnv(cfg map[string]string, si *serveSysInfo) {
|
||
if !sideSlotOn(cfg) {
|
||
return
|
||
}
|
||
if si.ArgEnv == nil {
|
||
si.ArgEnv = map[string]string{}
|
||
}
|
||
for _, k := range sideSlotArgEnv {
|
||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||
si.ArgEnv[k] = v
|
||
}
|
||
}
|
||
}
|
||
|
||
// sideSlotPlan décide de SIDE_SLOT pour ce lancement. ctx > 0 : accepté, chaque
|
||
// slot aura ctx jetons (le pool en fait le double) ; extraMiB est la VRAM de
|
||
// plus, estimée. ctx == 0 : refusé (why dit pourquoi), ou clé absente (why vide)
|
||
// — la ligne de commande est alors celle d'avant, à l'octet près.
|
||
func sideSlotPlan(cfg map[string]string, extra []string, si serveSysInfo) (ctx int, extraMiB int64, why string) {
|
||
if !sideSlotOn(cfg) {
|
||
return 0, 0, ""
|
||
}
|
||
if isExternalConfig(cfg) {
|
||
return 0, 0, "preset externe"
|
||
}
|
||
if p := strings.TrimSpace(cfg["PARALLEL"]); p != "" && p != "2" {
|
||
return 0, 0, "PARALLEL=" + p + " (SIDE_SLOT ouvre exactement 2 slots)"
|
||
}
|
||
for _, f := range sideSlotConflictFlags {
|
||
if hasAnyFlag(extra, f) {
|
||
return 0, 0, f + " est réglé dans EXTRA_ARGS"
|
||
}
|
||
}
|
||
for _, k := range sideSlotArgEnv {
|
||
if si.ArgEnv[k] != "" {
|
||
return 0, 0, k + " est posée"
|
||
}
|
||
}
|
||
if !strings.Contains(si.Help, "--no-kv-unified") {
|
||
return 0, 0, "ce moteur ne connaît pas --no-kv-unified"
|
||
}
|
||
n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX"]))
|
||
if strings.TrimSpace(cfg["CTX"]) == "" {
|
||
n, err = 32768, nil
|
||
}
|
||
if err != nil || n <= 0 {
|
||
return 0, 0, "CTX doit être un nombre de jetons explicite"
|
||
}
|
||
extraMiB, why = sideSlotVRAM(cfg, extra, si, n)
|
||
if why != "" {
|
||
return 0, 0, why
|
||
}
|
||
return n, extraMiB, ""
|
||
}
|
||
|
||
// sideSlotVRAM estime la VRAM du second slot (cache KV de ctx jetons et état
|
||
// récurrent) et vérifie que modèle + deux états tiennent dans 90 % de la VRAM,
|
||
// la même marge que cacheRAMAuto. Inconnu = refus : on ne double pas le cache
|
||
// KV à l'aveugle, c'est exactement la panne de mémoire qu'on a déjà vécue.
|
||
func sideSlotVRAM(cfg map[string]string, extra []string, si serveSysInfo, ctx int) (extraMiB int64, why string) {
|
||
switch {
|
||
case si.VRAMMiB <= 0:
|
||
return 0, "VRAM NVIDIA inconnue (macOS, AMD, --device…)"
|
||
case si.GGUF == nil:
|
||
return 0, "métadonnées GGUF illisibles"
|
||
case si.ModelBytes <= 0:
|
||
return 0, "taille du modèle inconnue"
|
||
}
|
||
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
|
||
return 0, r
|
||
}
|
||
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
|
||
if perTok <= 0 {
|
||
return 0, "taille d'état inconnue"
|
||
}
|
||
state := perTok*float64(ctx) + float64(rec)
|
||
if float64(si.ModelBytes)+2*state > 0.9*float64(si.VRAMMiB)*(1<<20) {
|
||
return 0, fmt.Sprintf("VRAM insuffisante : modèle + 2 × %d Mio d'état dépassent 90 %% de %d Mio",
|
||
int64(math.Ceil(state/(1<<20))), si.VRAMMiB)
|
||
}
|
||
return int64(math.Ceil(state / (1 << 20))), ""
|
||
}
|
||
|
||
// sideSlotNote : ce que le lancement dit de SIDE_SLOT, accepté ou refusé.
|
||
func sideSlotNote(ctx int, extraMiB int64, why string, nglForced bool) string {
|
||
if ctx <= 0 {
|
||
if why == "" {
|
||
return ""
|
||
}
|
||
return "SIDE_SLOT refusé (" + why + ") : un seul slot, comme sans la clé"
|
||
}
|
||
n := fmt.Sprintf("SIDE_SLOT : 2 slots de %d jetons (-c %d, --no-kv-unified), ~%d Mio de VRAM en plus "+
|
||
"pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte "+
|
||
"doublent aussi)", ctx, 2*ctx, extraMiB)
|
||
if nglForced {
|
||
return n + " ; NGL est imposé : surveille la VRAM au premier long prompt"
|
||
}
|
||
return n + " ; le placement automatique sortira des couches du GPU s'il manque de place"
|
||
}
|
||
|
||
// sideSlotPreview : l'avis de SIDE_SLOT pour l'éditeur de preset, calculé comme
|
||
// au lancement, à l'aide du moteur près (supposée connaître --no-kv-unified :
|
||
// un moteur trop ancien sera refusé au lancement, et le journal le dira).
|
||
func sideSlotPreview(content string) (on bool, extraMiB int64, why string) {
|
||
cfg := parseEnv(content)
|
||
if !sideSlotOn(cfg) {
|
||
return false, 0, ""
|
||
}
|
||
if isExternalConfig(cfg) {
|
||
return false, 0, "preset externe"
|
||
}
|
||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||
si := serveSysInfo{Help: "--no-kv-unified", ArgEnv: map[string]string{}}
|
||
for _, k := range append(append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...), sideSlotArgEnv...) {
|
||
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 false, 0, "modèle introuvable"
|
||
}
|
||
probeCacheRAM(cfg, extra, &si)
|
||
ctx, mib, why := sideSlotPlan(cfg, extra, si)
|
||
return ctx > 0, mib, why
|
||
}
|
||
|
||
// nglForced : le preset impose-t-il un nombre de couches GPU ? Alors le
|
||
// placement automatique ne tourne pas, et un second état de trop finit en
|
||
// panne de mémoire plutôt qu'en couches sorties du GPU. 999 est la sentinelle
|
||
// « toutes », traduite en auto sur un moteur récent (voir nglArgs).
|
||
func nglForced(cfg map[string]string, extra []string) bool {
|
||
v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||
if v == "" {
|
||
v = strings.TrimSpace(cfg["NGL"])
|
||
if v == "999" {
|
||
return false
|
||
}
|
||
}
|
||
return v != "" && !strings.EqualFold(v, "auto")
|
||
}
|