mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
En mode Code, le modèle recopie sans cesse ce qui est déjà dans le contexte : chemins, diffs, arguments JSON, fichiers réécrits. ngram-mod de llama.cpp y cherche la suite des 24 derniers jetons et propose d'un coup les 48 à 64 suivants, sans modèle brouillon. La clé SPEC (off par défaut, inchangé) gagne deux valeurs : ngram, et mtp+ngram qui y ajoute la tête MTP. Même vérification exacte que MTP : distribution inchangée, mais pas le même texte au bit près (vérification par lots), donc on compare des sessions rejouées, pas des diffs. - forme explicite seulement : --spec-type ngram-mod --spec-ngram-mod-n-match 24 --spec-ngram-mod-n-min 48 --spec-ngram-mod-n-max 64 ; jamais --spec-default (TODO amont, contenu susceptible de changer avec le moteur) - garde-fou d'aide sur « --spec-ngram-mod-n-match » (pas le simple mot ngram-mod, encore listé par les moteurs d'avant le renommage) ; absent = SPEC ignoré, note au journal - mtp+ngram : --spec-type draft-mtp,ngram-mod seulement avec draft-mtp dans l'aide ET une tête MTP réelle (tenseur nextn ou MODEL_DRAFT) ; sinon n-grammes seuls, avec la raison — jamais « failed to create MTP context » en boucle - EXTRA_ARGS/LLAMA_ARG_* : mêmes exclusions qu'avant, plus --spec_type et tout --spec-ngram-* (--spec-type s'additionne sans prévenir) ; pour ngram seul, un --spec-draft-* fait aussi taire Loki - poids sur CPU (experts MoE, -ot, NGL partiel) : refusé tant que GGML_OP_OFFLOAD_MIN_BATCH (env ou OP_OFFLOAD_MIN_BATCH) ne dépasse pas le lot de vérification de 65 jetons, avec la suggestion OP_OFFLOAD_MIN_BATCH=128 ; avertissements pour un modèle plus gros que la RAM, les points de reprise d'un hybride, NGL imposé (logits) - avec ngram, une tête MTP ou MODEL_DRAFT inutilisés sont signalés - --spec-synth-len/--spec-synth-rates (et LLAMA_ARG_SPEC_SYNTH_LEN) : acceptation au hasard, avertissement au lancement ; Loki ne les pose jamais - télémétrie : /api/perf/summary donne draft_n, draft_accepted et draft_rate par nature de requête - UI : deux options du sélecteur de décodage spéculatif ; README et configTemplate - tests : forme explicite, garde d'aide, repli de mtp+ngram, exclusions, seuil d'offload, ligne inchangée sans SPEC, acceptation par nature Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
725 lines
28 KiB
Go
725 lines
28 KiB
Go
package loki
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
bolt "go.etcd.io/bbolt"
|
||
)
|
||
|
||
// Décodage spéculatif géré par Loki : une tête MTP (prédiction de plusieurs
|
||
// jetons) intégrée au modèle ou publiée à part, ou un petit modèle brouillon.
|
||
//
|
||
// SPEC=off (défaut) → rien, même avec MODEL_DRAFT
|
||
// SPEC=auto → seulement si tout est réuni (voir specAutoBlocker)
|
||
// SPEC=mtp → imposé, sans les garde-fous d'auto, avec un avertissement
|
||
// MODEL_DRAFT=<nom> → la tête ou le brouillon, résolu comme MMPROJ
|
||
// SPEC_N_MAX=<n> → --spec-draft-n-max (défaut du moteur : 3)
|
||
// SPEC_SAMPLING=probabilistic → seulement avec SPEC=mtp ; greedy sinon
|
||
// SPEC=ngram → n-grammes tirés du contexte (ngram-mod), sans brouillon
|
||
// SPEC=mtp+ngram → les deux ; un n-gramme trouvé passe avant la tête MTP
|
||
//
|
||
// Rien n'y touche au modèle : chaque jeton émis est tiré par l'échantillonneur
|
||
// du modèle cible, un jeton du brouillon n'est gardé que s'il coïncide
|
||
// (common_sampler_sample_and_accept_n). Le prix est ailleurs : 1 à 2 Go de VRAM
|
||
// en plus, un prefill parfois plus lent, et une fonction très récente qui peut
|
||
// empêcher le moteur de démarrer. D'où l'opt-in, les garde-fous d'auto et le
|
||
// jeton de tentative (specAutoVerdict) : un lancement automatique qui n'a
|
||
// jamais répondu ne se retente pas avec la même configuration.
|
||
//
|
||
// Une vérification par lot n'emprunte pas les mêmes noyaux qu'un décodage jeton
|
||
// par jeton : même distribution, mais pas le même texte au bit près — deux
|
||
// candidats presque à égalité peuvent s'inverser à graine égale, comme entre
|
||
// prefill et décodage. On compare donc des sessions rejouées, pas des diffs.
|
||
|
||
const (
|
||
// specAutoMinBuild : premier build officiel où Loki ose l'auto (MTP serveur,
|
||
// PR #22673 et suites). Build inconnu = non : un compilé maison ou un fork
|
||
// peut connaître le drapeau sans le tenir.
|
||
specAutoMinBuild = 11009
|
||
specNMaxLimit = 64
|
||
|
||
// Bornes de ngram-mod, toujours écrites en clair : --spec-default pose les
|
||
// mêmes aujourd'hui, mais son code porte un TODO qui annonce d'autres types —
|
||
// une mise à jour du moteur changerait le réglage sans un mot. Un brouillon
|
||
// part dès 24 jetons qui se répètent mot pour mot, et fait 48 à 64 jetons :
|
||
// ce que recopie une boucle d'outils (chemins, diffs, arguments JSON).
|
||
specNgramNMatch = 24
|
||
specNgramNMin = 48
|
||
specNgramNMax = 64
|
||
|
||
// ggmlOffloadMinBatchDefault : seuil par défaut du moteur (ggml-cuda.cu) à
|
||
// partir duquel un lot recopie vers le GPU les poids restés en RAM.
|
||
ggmlOffloadMinBatchDefault = 32
|
||
)
|
||
|
||
// specArgEnv : les variables qui règlent la spéculation sans drapeau. Posées,
|
||
// elles valent un réglage d'EXTRA_ARGS : Loki se tait.
|
||
var specArgEnv = []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO",
|
||
"LLAMA_ARG_SPEC_DRAFT_N_MAX", "LLAMA_ARG_SPEC_DRAFT_SAMPLING"}
|
||
|
||
// specUserFlags : la spéculation réglée à la main. Les ajouter EN PLUS ferait
|
||
// cumuler les types (--spec-type s'additionne, sans l'avertissement « specified
|
||
// multiple times » des autres drapeaux) ou charger deux brouillons.
|
||
var specUserFlags = []string{"--spec-type", "--spec_type", "-md", "--model-draft", "--spec-draft-model",
|
||
"-hfd", "-hfrd", "--spec-draft-hf", "--hf-repo-draft", "--spec-default"}
|
||
|
||
// specUserPrefixes : des réglages de n-grammes à la main (--spec-ngram-mod-n-max,
|
||
// --spec-ngram-simple-size-n…) disent la même chose qu'un --spec-type.
|
||
var specUserPrefixes = []string{"--spec-ngram-", "--spec_ngram_"}
|
||
|
||
// hasFlagPrefix : un drapeau d'args commence-t-il par l'un de ces préfixes ?
|
||
func hasFlagPrefix(args []string, prefixes ...string) string {
|
||
for _, a := range args {
|
||
for _, p := range prefixes {
|
||
if strings.HasPrefix(a, p) {
|
||
name, _, _ := strings.Cut(a, "=")
|
||
return name
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// probeSpec : variables du moteur, puis MODEL_DRAFT résolu comme MMPROJ (nom
|
||
// simple cherché dans les dossiers déclarés, ou chemin absolu). Introuvable ne
|
||
// bloque PAS le lancement, contrairement au projecteur : le brouillon n'est
|
||
// qu'une accélération, le moteur démarre sans et la note dit pourquoi.
|
||
func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
||
if si.ArgEnv == nil {
|
||
si.ArgEnv = map[string]string{}
|
||
}
|
||
for _, k := range specArgEnv {
|
||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||
si.ArgEnv[k] = v
|
||
}
|
||
}
|
||
d := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||
if d == "" {
|
||
return
|
||
}
|
||
p, err := resolveServeModelPath(d)
|
||
if err == nil {
|
||
_, err = os.Stat(p)
|
||
}
|
||
if err != nil {
|
||
si.DraftErr = err.Error()
|
||
return
|
||
}
|
||
si.Draft = p
|
||
if g, err := ggufMeta(p); err == nil {
|
||
si.DraftGGUF = &g
|
||
}
|
||
}
|
||
|
||
// specSidecarArch : têtes de spéculation qui ne sont pas des modèles autonomes
|
||
// (llama.cpp : draft-eagle3, draft-dflash/dspark). Un -md vers elles avec
|
||
// draft-simple ne démarrerait pas : leur type se règle à la main.
|
||
func specSidecarArch(arch string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(arch)) {
|
||
case "eagle3", "dflash":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// specMode lit SPEC : "off", "auto", "mtp", "ngram", "mtp+ngram", ou "" si
|
||
// illisible.
|
||
func specMode(cfg map[string]string) string {
|
||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v {
|
||
case "", "off", "non", "no", "0":
|
||
return "off"
|
||
case "auto", "mtp", "ngram", "mtp+ngram":
|
||
return v
|
||
case "ngram+mtp":
|
||
return "mtp+ngram"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// specUserSet : EXTRA_ARGS ou l'environnement règlent déjà la spéculation.
|
||
func specUserSet(extra []string, argEnv map[string]string) bool {
|
||
if hasAnyFlag(extra, specUserFlags...) || hasFlagPrefix(extra, specUserPrefixes...) != "" {
|
||
return true
|
||
}
|
||
for _, k := range []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} {
|
||
if argEnv[k] != "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// helpSupportsMTP : le moteur connaît le type draft-mtp ET le drapeau actuel
|
||
// du nombre de jetons — un moteur qui n'a que l'ancien --draft-max est d'une
|
||
// autre génération, où un MTP n'existe pas.
|
||
func helpSupportsMTP(help string) bool {
|
||
return strings.Contains(help, "draft-mtp") && strings.Contains(help, "--spec-draft-n-max")
|
||
}
|
||
|
||
// ctxFixed : le contexte que verra le moteur (EXTRA_ARGS, sinon CTX, sinon le
|
||
// défaut de buildServeArgs), et s'il est chiffré. Avec 0 ou une valeur
|
||
// illisible, --fit a le droit de RÉDUIRE le contexte pour tenir.
|
||
func ctxFixed(cfg map[string]string, extra []string) (string, bool) {
|
||
c := flagValue(extra, "-c", "--ctx-size")
|
||
if c == "" {
|
||
c = strings.TrimSpace(cfg["CTX"])
|
||
}
|
||
if c == "" {
|
||
c = "32768"
|
||
}
|
||
n, err := strconv.Atoi(c)
|
||
return c, err == nil && n > 0
|
||
}
|
||
|
||
// statefulSampler : un échantillonneur dont l'état avance à chaque tirage
|
||
// (mirostat, adaptive-p). Le rejet probabiliste fait d'abord tirer le modèle
|
||
// cible, puis peut émettre un autre jeton : ces échantillonneurs avanceraient
|
||
// sur un jeton que la réponse ne contient pas. Le mode greedy, lui, reste exact.
|
||
func statefulSampler(extra []string) string {
|
||
for _, v := range flagValues(extra, "--mirostat") {
|
||
if v = strings.TrimSpace(v); v != "" && v != "0" {
|
||
return "--mirostat " + v
|
||
}
|
||
}
|
||
for _, v := range flagValues(extra, "--samplers") {
|
||
if strings.Contains(strings.ToLower(strings.ReplaceAll(v, "-", "_")), "adaptive_p") {
|
||
return "--samplers adaptive-p"
|
||
}
|
||
}
|
||
for _, v := range flagValues(extra, "--sampling-seq", "--sampler-seq") {
|
||
if strings.Contains(v, "a") {
|
||
return "--sampling-seq avec adaptive-p"
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// specAutoBlocker dit pourquoi SPEC=auto ne s'active pas, ou vide. Chaque
|
||
// garde répond à une panne précise :
|
||
//
|
||
// - qwen4exp : MTP fusionné il y a quelques jours, OOM signalés en multi-GPU ;
|
||
// - placement manuel (couches fixées, -ts, -ot, experts sur CPU, -sm row,
|
||
// --fit off, -dev) : seul --fit compte la VRAM de la tête MTP ;
|
||
// - contexte non chiffré : fit aurait le droit de le RÉDUIRE pour faire de
|
||
// la place — de l'information perdue pour le modèle ;
|
||
// - projecteur vision : une VRAM déjà serrée par un second modèle ;
|
||
// - build officiel inconnu ou trop ancien ;
|
||
// - un essai précédent avec la même configuration n'a jamais répondu.
|
||
func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
|
||
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
|
||
return "qwen4exp : MTP trop récent pour ce moteur, SPEC=mtp pour l'imposer"
|
||
}
|
||
if why := fitBlocker(cfg, extra, si); why != "" {
|
||
return "placement manuel (" + why + ") — --fit ne compte pas la VRAM du brouillon"
|
||
}
|
||
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
|
||
return "placement manuel (--device) — --fit ne compte pas la VRAM du brouillon"
|
||
}
|
||
if _, ok := ctxFixed(cfg, extra); !ok {
|
||
return "contexte non chiffré — fit pourrait le réduire pour loger le brouillon"
|
||
}
|
||
if si.MMProj != "" || hasAnyFlag(extra, "--mmproj", "-mm") {
|
||
return "projecteur vision chargé"
|
||
}
|
||
if si.EngineBuild < specAutoMinBuild {
|
||
if si.EngineBuild <= 0 {
|
||
return "build du moteur inconnu (compilé ou fork)"
|
||
}
|
||
return fmt.Sprintf("moteur b%d, b%d au moins", si.EngineBuild, specAutoMinBuild)
|
||
}
|
||
return si.SpecAutoBlocked
|
||
}
|
||
|
||
// specArgs : drapeaux de décodage spéculatif que Loki ajoute, ce qu'il en dit,
|
||
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
|
||
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
|
||
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
|
||
mode := specMode(cfg)
|
||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||
switch mode {
|
||
case "":
|
||
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif"}, false
|
||
case "off":
|
||
if draftKey != "" {
|
||
return nil, []string{"MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)"}, false
|
||
}
|
||
return nil, nil, false
|
||
}
|
||
if specUserSet(extra, si.ArgEnv) {
|
||
return nil, []string{"décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré"}, false
|
||
}
|
||
skip := func(why string) ([]string, []string, bool) {
|
||
return nil, []string{"SPEC=" + mode + " : " + why + " — sans décodage spéculatif"}, false
|
||
}
|
||
|
||
// N-grammes : leurs garde-fous d'abord, ils valent pour les deux formes.
|
||
if mode == "ngram" || mode == "mtp+ngram" {
|
||
if why := ngramBlocker(cfg, extra, si, mode); why != "" {
|
||
return skip(why)
|
||
}
|
||
}
|
||
if mode == "ngram" {
|
||
args, notes = ngramSpecArgs(cfg, extra, si, "")
|
||
return args, notes, false
|
||
}
|
||
|
||
src, mtp, why, quiet := specSource(mode, draftKey, si)
|
||
if mode == "mtp+ngram" && !mtp {
|
||
// Pas de tête MTP utilisable : la passer quand même ferait mourir le
|
||
// moteur sur « failed to create MTP context », et boucler. Les n-grammes,
|
||
// eux, n'ont besoin de rien.
|
||
if why == "" {
|
||
why = "MODEL_DRAFT n'est pas une tête MTP"
|
||
}
|
||
args, notes = ngramSpecArgs(cfg, extra, si, "SPEC=mtp+ngram : sans MTP ("+why+") → n-grammes seuls. ")
|
||
return args, notes, false
|
||
}
|
||
if why != "" {
|
||
return skip(why)
|
||
}
|
||
if quiet {
|
||
return nil, nil, false
|
||
}
|
||
args = src
|
||
if mode == "mtp+ngram" {
|
||
// Une seule liste : --spec-type s'additionne, mais une valeur unique se
|
||
// lit d'un coup d'œil dans le journal.
|
||
args[len(args)-1] = "draft-mtp,ngram-mod"
|
||
args = append(args, ngramModParams()...)
|
||
}
|
||
|
||
label := "brouillon " + baseName(si.Draft)
|
||
if mtp {
|
||
label = "tête MTP"
|
||
}
|
||
if mode == "auto" {
|
||
if why := specAutoBlocker(cfg, extra, si); why != "" {
|
||
return skip(why)
|
||
}
|
||
auto = true
|
||
notes = append(notes, "SPEC=auto → "+label+" : sortie inchangée (chaque jeton est vérifié par le modèle), "+
|
||
"~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.")
|
||
} else {
|
||
note := "SPEC=" + mode + " → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
|
||
if mode == "mtp+ngram" {
|
||
note += " ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)"
|
||
}
|
||
if why := fitBlocker(cfg, extra, si); why != "" || hasAnyFlag(extra, "-dev", "--device") {
|
||
if why == "" {
|
||
why = "--device"
|
||
}
|
||
note += " ; placement manuel (" + why + ") : --fit ne compte pas le brouillon, " +
|
||
"place-le avec -devd / -ngld si la VRAM manque"
|
||
}
|
||
if _, ok := ctxFixed(cfg, extra); !ok {
|
||
note += " ; contexte non chiffré : fit pourrait le réduire pour loger le brouillon"
|
||
}
|
||
if si.GGUF != nil && strings.EqualFold(si.GGUF.Arch, "qwen4exp") {
|
||
note += " ; MTP qwen4exp très récent"
|
||
}
|
||
notes = append(notes, note)
|
||
if mode == "mtp+ngram" {
|
||
notes = append(notes, ngramCostNotes(cfg, extra, si)...)
|
||
}
|
||
}
|
||
|
||
// Nombre de jetons anticipés : défaut du moteur (3) si la clé est vide.
|
||
if v := strings.TrimSpace(cfg["SPEC_N_MAX"]); v != "" {
|
||
n, err := strconv.Atoi(v)
|
||
switch {
|
||
case err != nil || n < 1 || n > specNMaxLimit:
|
||
notes = append(notes, "SPEC_N_MAX="+v+" illisible (1 à 64 jetons) : défaut du moteur")
|
||
case !strings.Contains(si.Help, "--spec-draft-n-max"):
|
||
notes = append(notes, "ce moteur ne connaît pas --spec-draft-n-max : SPEC_N_MAX ignoré")
|
||
case hasAnyFlag(extra, "--spec-draft-n-max") || si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_N_MAX"] != "":
|
||
default:
|
||
args = append(args, "--spec-draft-n-max", strconv.Itoa(n))
|
||
}
|
||
}
|
||
|
||
// Tirage du brouillon. greedy est exact quelle que soit la chaîne
|
||
// d'échantillonnage : on le fixe dès que le moteur connaît le drapeau, pour
|
||
// qu'un changement de défaut en amont ne change rien ici.
|
||
sampling := "greedy"
|
||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC_SAMPLING"])); v {
|
||
case "", "greedy":
|
||
case "probabilistic":
|
||
switch {
|
||
case mode == "auto":
|
||
notes = append(notes, "SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy")
|
||
case statefulSampler(extra) != "":
|
||
notes = append(notes, "SPEC_SAMPLING=probabilistic refusé avec "+statefulSampler(extra)+
|
||
" (cet échantillonneur avancerait sur des jetons rejetés) : greedy")
|
||
case !strings.Contains(si.Help, "probabilistic"):
|
||
notes = append(notes, "ce moteur ne connaît pas --spec-draft-sampling probabilistic : greedy")
|
||
default:
|
||
sampling = v
|
||
}
|
||
default:
|
||
notes = append(notes, "SPEC_SAMPLING="+v+" illisible (greedy ou probabilistic) : greedy")
|
||
}
|
||
if strings.Contains(si.Help, "--spec-draft-sampling") && !hasAnyFlag(extra, "--spec-draft-sampling") &&
|
||
si.ArgEnv["LLAMA_ARG_SPEC_DRAFT_SAMPLING"] == "" {
|
||
args = append(args, "--spec-draft-sampling", sampling)
|
||
}
|
||
return args, notes, auto
|
||
}
|
||
|
||
// specSource : d'où vient le brouillon de SPEC=auto|mtp|mtp+ngram. MODEL_DRAFT
|
||
// s'il est posé, sinon la tête MTP du modèle ; dans les deux cas on regarde les
|
||
// TENSEURS, comme llama.cpp : une clé nextn_predict_layers seule ne prouve pas
|
||
// que la tête est dans le fichier. why non vide = aucun brouillon ; quiet =
|
||
// rien à dire (auto sur un modèle sans MTP).
|
||
func specSource(mode, draftKey string, si serveSysInfo) (args []string, mtp bool, why string, quiet bool) {
|
||
switch {
|
||
case draftKey != "":
|
||
switch {
|
||
case si.Draft == "":
|
||
why := "MODEL_DRAFT=" + draftKey + " introuvable"
|
||
if si.DraftErr != "" {
|
||
why += " (" + si.DraftErr + ")"
|
||
}
|
||
return nil, false, why, false
|
||
case si.DraftGGUF == nil:
|
||
return nil, false, "MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)", false
|
||
case si.DraftGGUF.HasNextNTensor:
|
||
if !helpSupportsMTP(si.Help) {
|
||
return nil, false, "ce moteur ne connaît pas draft-mtp", false
|
||
}
|
||
// Type explicite : llama.cpp ne le devine que sur la première tranche.
|
||
return []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true, "", false
|
||
case specSidecarArch(si.DraftGGUF.Arch):
|
||
return nil, false, "MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
|
||
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS", false
|
||
case mode == "mtp+ngram":
|
||
return nil, false, "", false // un modèle brouillon n'est pas une tête MTP
|
||
default:
|
||
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
|
||
return nil, false, "ce moteur ne connaît pas --spec-type draft-simple", false
|
||
}
|
||
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
|
||
// VRAM puis jamais utilisé.
|
||
return []string{"-md", si.Draft, "--spec-type", "draft-simple"}, false, "", false
|
||
}
|
||
case si.GGUF == nil:
|
||
return nil, false, "métadonnées du modèle illisibles", false
|
||
case !si.GGUF.HasNextNTensor:
|
||
if mode != "auto" || si.GGUF.NextN > 0 {
|
||
return nil, false, "pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)", false
|
||
}
|
||
return nil, false, "", true
|
||
case !helpSupportsMTP(si.Help):
|
||
return nil, false, "ce moteur ne connaît pas draft-mtp", false
|
||
}
|
||
return []string{"--spec-type", "draft-mtp"}, true, "", false
|
||
}
|
||
|
||
// ngramModParams : les trois bornes de ngram-mod, toujours explicites.
|
||
func ngramModParams() []string {
|
||
return []string{
|
||
"--spec-ngram-mod-n-match", strconv.Itoa(specNgramNMatch),
|
||
"--spec-ngram-mod-n-min", strconv.Itoa(specNgramNMin),
|
||
"--spec-ngram-mod-n-max", strconv.Itoa(specNgramNMax),
|
||
}
|
||
}
|
||
|
||
// ngramBlocker dit pourquoi SPEC=ngram|mtp+ngram ne s'active pas, ou vide.
|
||
//
|
||
// - Le moteur doit connaître --spec-ngram-mod-n-match, pas seulement le mot
|
||
// « ngram-mod » : les moteurs d'avant le renommage ont encore le type dans
|
||
// leur liste mais refusent les drapeaux actuels, et meurent au démarrage.
|
||
// - SPEC=ngram n'a pas de brouillon : un --spec-draft-* d'EXTRA_ARGS veut dire
|
||
// qu'on règle un brouillon à la main, on ne mélange pas.
|
||
// - Poids en RAM (experts MoE sur CPU…) : llama.cpp recopie vers le GPU les
|
||
// poids d'un lot dès GGML_OP_OFFLOAD_MIN_BATCH jetons (32 par défaut). Un
|
||
// lot de vérification de n_max+1 jetons au-dessus du seuil ferait passer
|
||
// les experts de chaque couche par le PCIe — voire par le disque si le
|
||
// modèle mappé dépasse la RAM — à chaque brouillon. Refusé tant que le seuil
|
||
// n'est pas relevé au-dessus du lot.
|
||
func ngramBlocker(cfg map[string]string, extra []string, si serveSysInfo, mode string) string {
|
||
if !strings.Contains(si.Help, "--spec-ngram-mod-n-match") {
|
||
return "ce moteur ne connaît pas --spec-ngram-mod-n-match"
|
||
}
|
||
if mode == "ngram" {
|
||
if f := hasFlagPrefix(extra, "--spec-draft-", "--spec_draft_"); f != "" {
|
||
return f + " dans EXTRA_ARGS (brouillon réglé à la main)"
|
||
}
|
||
}
|
||
blocks := 0
|
||
if si.GGUF != nil {
|
||
blocks = si.GGUF.BlockCount
|
||
}
|
||
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
|
||
if thr := opOffloadThreshold(cfg, si); specNgramNMax+1 >= thr {
|
||
return fmt.Sprintf("%s : un lot de vérification de %d jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (%d) et "+
|
||
"recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer",
|
||
r, specNgramNMax+1, thr)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// opOffloadThreshold : le seuil GGML_OP_OFFLOAD_MIN_BATCH que verra le moteur —
|
||
// celui de l'environnement, sinon celui d'OP_OFFLOAD_MIN_BATCH, sinon le défaut.
|
||
func opOffloadThreshold(cfg map[string]string, si serveSysInfo) int {
|
||
v := strings.TrimSpace(si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"])
|
||
if v == "" {
|
||
v, _ = opOffloadMinBatchEnv(cfg, si)
|
||
}
|
||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||
return n
|
||
}
|
||
return ggmlOffloadMinBatchDefault
|
||
}
|
||
|
||
// ngramSpecArgs : SPEC=ngram, ou le repli de mtp+ngram sans tête MTP (lead dit
|
||
// pourquoi). Pas de --spec-draft-* : il n'y a pas de brouillon.
|
||
func ngramSpecArgs(cfg map[string]string, extra []string, si serveSysInfo, lead string) (args, notes []string) {
|
||
args = append([]string{"--spec-type", "ngram-mod"}, ngramModParams()...)
|
||
notes = append(notes, lead+fmt.Sprintf("SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de %d à %d jetons) : "+
|
||
"distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer "+
|
||
"(acceptation par nature dans /api/perf/summary)", specNgramNMin, specNgramNMax))
|
||
if lead == "" {
|
||
// --spec-type explicite : llama.cpp ne choisit plus de lui-même la tête MTP.
|
||
switch {
|
||
case strings.TrimSpace(cfg["MODEL_DRAFT"]) != "":
|
||
notes = append(notes, "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
|
||
case si.GGUF != nil && si.GGUF.HasNextNTensor:
|
||
notes = append(notes, "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
|
||
}
|
||
}
|
||
if strings.TrimSpace(cfg["SPEC_N_MAX"]) != "" || strings.TrimSpace(cfg["SPEC_SAMPLING"]) != "" {
|
||
notes = append(notes, "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler")
|
||
}
|
||
return args, append(notes, ngramCostNotes(cfg, extra, si)...)
|
||
}
|
||
|
||
// ngramCostNotes : ce que les longs brouillons coûtent, là où ça se voit.
|
||
func ngramCostNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
|
||
var notes []string
|
||
if si.GGUF != nil && si.GGUF.Hybrid {
|
||
notes = append(notes, "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM "+
|
||
"hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit")
|
||
}
|
||
blocks := 0
|
||
if si.GGUF != nil {
|
||
blocks = si.GGUF.BlockCount
|
||
}
|
||
if cpuWeights(cfg, extra, si.ArgEnv, blocks) != "" && si.ModelBytes > 0 && si.RAMMiB > 0 &&
|
||
si.ModelBytes > si.RAMMiB<<20 {
|
||
notes = append(notes, "modèle plus gros que la RAM, poids sur CPU : un lot de vérification lit la plupart des "+
|
||
"experts de chaque couche — surveille les lectures disque, pas seulement les jetons/s")
|
||
}
|
||
if nglForced(cfg, extra) {
|
||
notes = append(notes, fmt.Sprintf("NGL imposé (--fit inactif) : les logits de %d positions par slot prennent "+
|
||
"quelques dizaines de Mio de VRAM en plus", specNgramNMax+1))
|
||
}
|
||
return notes
|
||
}
|
||
|
||
// --- Jeton de tentative ------------------------------------------------------
|
||
//
|
||
// Un essai automatique qui fait tomber le moteur ne doit pas se rejouer : sous
|
||
// systemd il redémarrerait en boucle en rechargeant 15 Go à chaque fois. cmdServe
|
||
// ne peut pas constater l'échec lui-même (sous Unix il DEVIENT llama-server).
|
||
// Il pose donc un jeton avant de lancer ; le process web l'efface dès que le
|
||
// moteur répond. Au lancement suivant, un jeton encore là pour la même
|
||
// configuration, le même moteur et le même build veut dire « jamais répondu » :
|
||
// l'auto est coupé pour cette combinaison, et on le dit. Une mise à jour du
|
||
// moteur ou un preset modifié retente. Sans process web (CLI seule), le jeton
|
||
// n'est jamais effacé : on retombe sur off, le côté sûr.
|
||
|
||
const (
|
||
specAttemptKey = "spec_auto_attempt"
|
||
specFailedKey = "spec_auto_failed"
|
||
specFailedMax = 32
|
||
)
|
||
|
||
// specAutoMark identifie une combinaison preset × moteur.
|
||
type specAutoMark struct {
|
||
FP string `json:"fp"`
|
||
Bin string `json:"bin"`
|
||
Build int `json:"build"`
|
||
}
|
||
|
||
func (m specAutoMark) key() string { return fmt.Sprintf("%s|%s|%d", m.FP, m.Bin, m.Build) }
|
||
|
||
// specAutoVerdict : pure. Renvoie la raison de couper l'auto (vide = permis)
|
||
// et s'il faut inscrire cur parmi les échecs.
|
||
func specAutoVerdict(cur specAutoMark, attempt *specAutoMark, failed map[string]string) (why string, record bool) {
|
||
if w := failed[cur.key()]; w != "" {
|
||
return w, false
|
||
}
|
||
if attempt != nil && attempt.key() == cur.key() {
|
||
return "le dernier lancement avec cette configuration n'a jamais répondu", true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// specAutoPeek lit l'état sans rien changer : la raison de couper l'auto (vide
|
||
// = permis) et s'il faudra inscrire l'échec. Rien n'est consommé avant que le
|
||
// port soit libre (specAutoSettle) : un second « loki serve », refusé parce que
|
||
// le premier charge encore, ne doit pas prendre le jeton de celui-ci pour un
|
||
// échec.
|
||
func specAutoPeek(cur specAutoMark) (why string, record bool) {
|
||
_ = view(bkState, func(b *bolt.Bucket) error {
|
||
attempt, failed := readSpecAuto(b)
|
||
why, record = specAutoVerdict(cur, attempt, failed)
|
||
return nil
|
||
})
|
||
return why, record
|
||
}
|
||
|
||
// specAutoSettle range, port libre, juste avant de lancer le moteur : l'ancien
|
||
// jeton est consommé, l'échec inscrit s'il y a lieu (record, why), et un
|
||
// nouveau jeton posé si ce lancement ajoute des drapeaux automatiques.
|
||
func specAutoSettle(cur specAutoMark, why string, record, attempt bool) {
|
||
_ = update(bkState, func(b *bolt.Bucket) error {
|
||
_, failed := readSpecAuto(b)
|
||
_ = b.Delete([]byte(specAttemptKey))
|
||
if record {
|
||
if err := putFailed(b, failed, cur.key(), why); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if !attempt {
|
||
return nil
|
||
}
|
||
raw, err := json.Marshal(cur)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return b.Put([]byte(specAttemptKey), raw)
|
||
})
|
||
}
|
||
|
||
// readSpecAuto : le jeton en cours (nil = aucun) et les échecs inscrits.
|
||
func readSpecAuto(b *bolt.Bucket) (attempt *specAutoMark, failed map[string]string) {
|
||
if raw := b.Get([]byte(specAttemptKey)); raw != nil {
|
||
var a specAutoMark
|
||
if json.Unmarshal(raw, &a) == nil {
|
||
attempt = &a
|
||
}
|
||
}
|
||
failed = map[string]string{}
|
||
if raw := b.Get([]byte(specFailedKey)); raw != nil {
|
||
_ = json.Unmarshal(raw, &failed)
|
||
}
|
||
return attempt, failed
|
||
}
|
||
|
||
func putFailed(b *bolt.Bucket, failed map[string]string, key, why string) error {
|
||
if len(failed) >= specFailedMax {
|
||
failed = map[string]string{}
|
||
}
|
||
failed[key] = why
|
||
raw, err := json.Marshal(failed)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return b.Put([]byte(specFailedKey), raw)
|
||
}
|
||
|
||
// --- Côté process web --------------------------------------------------------
|
||
|
||
var (
|
||
specWatchOnce sync.Once
|
||
offloadedRe = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`)
|
||
)
|
||
|
||
// startSpecAttemptWatch : une goroutine qui efface le jeton dès que le moteur
|
||
// répond, sans attendre qu'une page interroge /api/status.
|
||
func startSpecAttemptWatch() {
|
||
specWatchOnce.Do(func() {
|
||
go func() {
|
||
t := time.NewTicker(5 * time.Second)
|
||
defer t.Stop()
|
||
for range t.C {
|
||
specAttemptTick()
|
||
}
|
||
}()
|
||
})
|
||
}
|
||
|
||
func specAttemptTick() {
|
||
var a specAutoMark
|
||
if !getJSON(bkState, specAttemptKey, &a) {
|
||
return
|
||
}
|
||
if externalActive() {
|
||
specAttemptClear(a, "") // plus de moteur local à attendre
|
||
return
|
||
}
|
||
if !healthCheck() {
|
||
return
|
||
}
|
||
// Répondu. Mais avec -c fixé, fit ne réduit pas le contexte : s'il manque
|
||
// la place du brouillon, il descend des couches en RAM — le moteur tourne,
|
||
// plus lentement que sans MTP. Ça compte aussi comme un échec de l'auto.
|
||
why := ""
|
||
if n, m := lastOffload(serviceLogTail(600)); m > 0 && n < m {
|
||
why = fmt.Sprintf("seulement %d/%d couches sur GPU avec le brouillon : fit a déplacé des couches en RAM", n, m)
|
||
fmt.Println("[loki] SPEC=auto : " + why + " — coupé au prochain démarrage (SPEC=mtp pour l'imposer)")
|
||
}
|
||
specAttemptClear(a, why)
|
||
}
|
||
|
||
// specAttemptClear efface le jeton a — et lui seul : relu sous verrou, car un
|
||
// « loki serve » a pu entre-temps poser celui d'un AUTRE lancement, qui n'a pas
|
||
// encore répondu. why non vide inscrit l'échec de a.
|
||
func specAttemptClear(a specAutoMark, why string) {
|
||
_ = update(bkState, func(b *bolt.Bucket) error {
|
||
cur, failed := readSpecAuto(b)
|
||
if cur == nil || cur.key() != a.key() {
|
||
return nil
|
||
}
|
||
_ = b.Delete([]byte(specAttemptKey))
|
||
if why == "" {
|
||
return nil
|
||
}
|
||
return putFailed(b, failed, a.key(), why)
|
||
})
|
||
}
|
||
|
||
// lastOffload lit les lignes « offloaded N/M layers to GPU » du DERNIER
|
||
// chargement du journal, repéré à la ligne du serveur « loading model
|
||
// '<chemin>' ». Pas à « load_model » (le serveur écrit encore « load_model:
|
||
// initializing… » APRÈS le chargement) ni à « loading model tensors » (écrit
|
||
// aussi pour le brouillon, qui masquerait le modèle). Un brouillon chargé à
|
||
// part a sa propre ligne : la pire des deux compte. 0, 0 = introuvable : on ne
|
||
// conclut rien.
|
||
func lastOffload(log string) (n, m int) {
|
||
lines := strings.Split(log, "\n")
|
||
start := -1
|
||
for i, l := range lines {
|
||
if strings.Contains(l, "loading model '") {
|
||
start = i
|
||
}
|
||
}
|
||
if start < 0 {
|
||
return 0, 0
|
||
}
|
||
for _, l := range lines[start:] {
|
||
s := offloadedRe.FindStringSubmatch(l)
|
||
if s == nil {
|
||
continue
|
||
}
|
||
a, _ := strconv.Atoi(s[1])
|
||
b, _ := strconv.Atoi(s[2])
|
||
if m == 0 || a < b {
|
||
n, m = a, b
|
||
}
|
||
if a < b {
|
||
break
|
||
}
|
||
}
|
||
return n, m
|
||
}
|