Files
Loki/internal/loki/backend_serve_spec.go
T
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00

747 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 a, err := os.Stat(p); err == nil {
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
si.DraftIsModel = true
}
}
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) {
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
// (common_speculative_init_result charge tout fichier -md comme un modèle
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
// lieu de 22. On l'ignore : même tête, sans les poids en double.
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
c := make(map[string]string, len(cfg))
for k, v := range cfg {
c[k] = v
}
delete(c, "MODEL_DRAFT")
args, notes, auto = specArgs(c, extra, si)
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
}
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
}