Spéculation : SPEC=ngram et mtp+ngram, n-grammes du contexte en opt-in

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>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 10:28:25 +02:00
1 parent 28ba44eb46
commit 539e36ea49
11 files changed
+495 -67

No files matched your search

+1 -1
View File
@@ -347,7 +347,7 @@ func cmdServe(args []string) error {
probeCkptEnv(&si)
probeSideSlotEnv(cfg, &si)
spec := specMode(cfg)
if spec == "auto" || spec == "mtp" {
if spec != "off" && spec != "" {
probeSpec(cfg, &si)
}
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
+11 -1
View File
@@ -23,7 +23,7 @@ import (
// ils décident. LLAMA_ARG_NO_CONTEXT_SHIFT est celle des moteurs anciens.
var fidelityArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V",
"LLAMA_ARG_CONTEXT_SHIFT", "LLAMA_ARG_NO_CONTEXT_SHIFT", "LLAMA_ARG_CACHE_REUSE",
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL"}
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL", "LLAMA_ARG_SPEC_SYNTH_LEN"}
// probeFidelityEnv complète ArgEnv avec fidelityArgEnv. Appelée après
// probeServeGPUs, qui crée ArgEnv.
@@ -168,6 +168,16 @@ func lossyCacheNotes(extra []string, si serveSysInfo) []string {
"de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul "+
"complet%s.", n, src, ignored))
}
// Acceptation SYNTHÉTIQUE (« benchmarking only ») : le moteur garde des
// jetons du brouillon au hasard, sans les comparer à ce qu'aurait tiré le
// modèle. Loki ne les pose jamais ; écrits à la main, on le crie.
if f := hasFlagPrefix(extra, "--spec-synth-"); f != "" || si.ArgEnv["LLAMA_ARG_SPEC_SYNTH_LEN"] != "" {
if f == "" {
f = "LLAMA_ARG_SPEC_SYNTH_LEN"
}
notes = append(notes, "avertissement : "+f+" — le moteur accepte des jetons du brouillon AU HASARD, sans "+
"vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le.")
}
return notes
}
+238 -48
View File
@@ -22,6 +22,8 @@ import (
// 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
@@ -30,6 +32,11 @@ import (
// 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,
@@ -37,6 +44,19 @@ const (
// 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,
@@ -45,10 +65,28 @@ var specArgEnv = []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "
"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) ou charger deux brouillons.
var specUserFlags = []string{"--spec-type", "-md", "--model-draft", "--spec-draft-model",
// 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
@@ -91,20 +129,23 @@ func specSidecarArch(arch string) bool {
return false
}
// specMode lit SPEC : "off", "auto", "mtp", ou "" si illisible.
// 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":
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...) {
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"} {
@@ -204,7 +245,7 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
case "":
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto ou mtp) : sans décodage spéculatif"}, false
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
@@ -218,49 +259,40 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
return nil, []string{"SPEC=" + mode + " : " + why + " — sans décodage spéculatif"}, false
}
// La source : 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.
mtp := false
switch {
case draftKey != "":
switch {
case si.Draft == "":
why := "MODEL_DRAFT=" + draftKey + " introuvable"
if si.DraftErr != "" {
why += " (" + si.DraftErr + ")"
}
// 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)
case si.DraftGGUF == nil:
return skip("MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)")
case si.DraftGGUF.HasNextNTensor:
if !helpSupportsMTP(si.Help) {
return skip("ce moteur ne connaît pas draft-mtp")
}
// Type explicite : llama.cpp ne le devine que sur la première tranche.
args, mtp = []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true
case specSidecarArch(si.DraftGGUF.Arch):
return skip("MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS")
default:
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
return skip("ce moteur ne connaît pas --spec-type draft-simple")
}
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
// VRAM puis jamais utilisé.
args = []string{"-md", si.Draft, "--spec-type", "draft-simple"}
}
case si.GGUF == nil:
return skip("métadonnées du modèle illisibles")
case !si.GGUF.HasNextNTensor:
if mode == "mtp" || si.GGUF.NextN > 0 {
return skip("pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)")
}
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"
}
return nil, nil, false // auto sur un modèle sans MTP : rien à dire
case !helpSupportsMTP(si.Help):
return skip("ce moteur ne connaît pas draft-mtp")
default:
args, mtp = []string{"--spec-type", "draft-mtp"}, true
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)
@@ -275,7 +307,10 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
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=mtp → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
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"
@@ -290,6 +325,9 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
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.
@@ -314,7 +352,7 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
case "", "greedy":
case "probabilistic":
switch {
case mode != "mtp":
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)+
@@ -334,6 +372,158 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
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
+161
View File
@@ -332,3 +332,164 @@ func TestLastOffload(t *testing.T) {
t.Errorf("tout sur GPU : got %d/%d", n, m)
}
}
// helpNgram : un moteur qui connaît les drapeaux actuels de ngram-mod.
const helpNgram = helpSpec + `--spec-ngram-mod-n-min N minimum number of ngram tokens (default: 48)
--spec-ngram-mod-n-max N maximum number of ngram tokens (default: 64)
--spec-ngram-mod-n-match N ngram-mod lookup length (default: 24)
`
func TestSpecArgsNgram(t *testing.T) {
ngram := []string{"--spec-type", "ngram-mod",
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64"}
both := []string{"--spec-type", "draft-mtp,ngram-mod",
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64",
"--spec-draft-sampling", "greedy"}
cfgOf := func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
dense := func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "ngram, modèle dense : forme explicite, jamais --spec-default",
cfg: cfgOf("SPEC", "ngram"), si: dense, want: ngram, notes: 1},
{name: "ngram+mtp s'écrit aussi dans l'autre sens",
cfg: cfgOf("SPEC", "ngram+mtp"), want: both, notes: 2},
{name: "ngram sur un modèle hybride à tête MTP : tête inutilisée et points de reprise dits",
cfg: cfgOf("SPEC", "ngram"), want: ngram, notes: 3},
{name: "ngram, moteur qui n'a que le type dans sa liste : rien",
cfg: cfgOf("SPEC", "ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
{name: "mtp+ngram, moteur sans drapeaux ngram-mod : rien",
cfg: cfgOf("SPEC", "mtp+ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
{name: "mtp+ngram avec la tête dans le fichier : une seule liste de types",
cfg: cfgOf("SPEC", "mtp+ngram"), want: both, notes: 2},
{name: "mtp+ngram sans tête MTP : n-grammes seuls, jamais « failed to create MTP context »",
cfg: cfgOf("SPEC", "mtp+ngram"), si: dense, want: ngram, notes: 1},
{name: "mtp+ngram, moteur sans draft-mtp : n-grammes seuls",
cfg: cfgOf("SPEC", "mtp+ngram"), want: ngram, notes: 2,
si: func(s *serveSysInfo) {
s.Help = strings.ReplaceAll(s.Help, "draft-mtp", "draft-xxx")
}},
{name: "mtp+ngram avec un petit modèle brouillon : n-grammes seuls, pas de -md",
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "Qwen3-0.6B.gguf"), want: ngram, notes: 2,
si: func(s *serveSysInfo) {
s.Draft = "/models/Qwen3-0.6B.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
}},
{name: "mtp+ngram avec la tête MTP publiée à part",
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "mtp-q.gguf"), notes: 2,
want: append([]string{"-md", "/models/mtp-q.gguf"}, both...),
si: func(s *serveSysInfo) {
s.GGUF.HasNextNTensor = false
s.Draft = "/models/mtp-q.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "ngram + MODEL_DRAFT : brouillon ignoré, et dit",
cfg: cfgOf("SPEC", "ngram", "MODEL_DRAFT", "mtp-q.gguf"), si: dense, want: ngram, notes: 2},
{name: "ngram + SPEC_N_MAX : ignoré, et dit",
cfg: cfgOf("SPEC", "ngram", "SPEC_N_MAX", "4"), si: dense, want: ngram, notes: 2},
{name: "--spec-type dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-type", "ngram-simple"}, notes: 1},
{name: "--spec_type=… dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec_type=ngram-mod"}, notes: 1},
{name: "--spec-default dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-default"}, notes: 1},
{name: "--spec-ngram-mod-n-max dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-ngram-mod-n-max=16"}, notes: 1},
{name: "-md dans EXTRA_ARGS : Loki se tait",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-md", "d.gguf"}, notes: 1},
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: cfgOf("SPEC", "ngram"), notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "ngram-mod" }},
{name: "ngram + --spec-draft-n-max à la main : rien",
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--spec-draft-n-max", "8"}, notes: 1},
{name: "mtp+ngram + --spec-draft-n-max à la main : la règle de SPEC=mtp",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-draft-n-max", "8"}, want: both, notes: 2},
{name: "experts sur CPU, seuil par défaut : refusé, OP_OFFLOAD_MIN_BATCH suggéré",
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--n-cpu-moe", "40"}, notes: 1},
{name: "experts sur CPU, mtp+ngram : refusé aussi",
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-ot", "exps=CPU"}, notes: 1},
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=64 : encore sous le lot de 65, refusé",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "64"), si: dense, extra: []string{"-cmoe"}, notes: 1},
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=128 : accepté",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), si: dense, extra: []string{"-cmoe"}, want: ngram, notes: 1},
{name: "experts sur CPU, GGML_OP_OFFLOAD_MIN_BATCH=256 dans l'environnement : accepté",
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--n-cpu-moe", "40"}, want: ngram, notes: 1,
si: func(s *serveSysInfo) {
dense(s)
s.UserEnv = map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "256"}
}},
{name: "experts sur CPU, modèle plus gros que la RAM : accepté avec l'avertissement",
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), extra: []string{"--n-cpu-moe", "40"},
want: ngram, notes: 2,
si: func(s *serveSysInfo) {
dense(s)
s.ModelBytes, s.RAMMiB = 82<<30, 64<<10
}},
{name: "NGL imposé : logits en VRAM dits",
cfg: cfgOf("SPEC", "ngram", "NGL", "40"), si: dense, want: ngram, notes: 2},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := mtpReady()
si.Help = helpNgram
if c.si != nil {
c.si(&si)
}
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("args = %q, attendu %q", got, c.want)
}
if isAuto {
t.Error("ngram n'est jamais automatique")
}
if len(notes) != c.notes {
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
}
for _, a := range got {
if a == "--spec-default" || strings.HasPrefix(a, "--spec-synth") {
t.Errorf("drapeau interdit %q", a)
}
}
})
}
}
// Sans SPEC, la même ligne qu'avant, quel que soit le moteur.
func TestBuildServeArgsNgramDefault(t *testing.T) {
si := mtpReady()
si.Help = helpNgram
base, _, _ := buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
for _, a := range base {
if strings.HasPrefix(a, "--spec") {
t.Fatalf("SPEC absent : aucun drapeau de spéculation, %q", base)
}
}
got, _, _ := buildServeArgs(map[string]string{"SPEC": "ngram"}, []string{"--jinja"}, "/bin/llama-server", si)
i := slices.Index(got, "--spec-type")
if i < 0 || got[i+1] != "ngram-mod" || slices.Index(got, "--jinja") < i {
t.Fatalf("SPEC=ngram : %q", got)
}
}
func TestSynthAcceptanceWarned(t *testing.T) {
for _, extra := range [][]string{{"--spec-synth-len", "3"}, {"--spec-synth-rates=0.5,0.5"}} {
if n := lossyCacheNotes(extra, serveSysInfo{ArgEnv: map[string]string{}}); len(n) != 1 ||
!strings.Contains(n[0], "AU HASARD") {
t.Errorf("%q : %q", extra, n)
}
}
si := serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPEC_SYNTH_LEN": "3"}}
if n := lossyCacheNotes(nil, si); len(n) != 1 {
t.Errorf("variable : %q", n)
}
}
+12
View File
@@ -453,6 +453,12 @@ type perfKindSum struct {
LostEvents int `json:"lost_events"`
LostTokens int `json:"lost_tokens"`
TTFTMedianMs *float64 `json:"ttft_median_ms,omitempty"`
// Acceptation du brouillon par nature : un n-gramme paie en mode code
// (chemins, diffs recopiés) et presque pas en prose — le taux global
// mélangerait les deux. Absent = moteur muet ou spéculation coupée.
DraftN int `json:"draft_n,omitempty"`
DraftAccepted int `json:"draft_accepted,omitempty"`
DraftRate *float64 `json:"draft_rate,omitempty"`
}
type perfDepthSum struct {
@@ -550,8 +556,10 @@ func perfSummarize(recs []perfRec) perfSummary {
}
if r.DraftN != nil {
s.DraftN += *r.DraftN
k.DraftN += *r.DraftN
if r.DraftAcc != nil {
s.DraftAccepted += *r.DraftAcc
k.DraftAccepted += *r.DraftAcc
}
}
}
@@ -561,6 +569,10 @@ func perfSummarize(recs []perfRec) perfSummary {
k.PrefillSecPerTurn = k.PrefillSec / float64(k.Turns)
}
k.TTFTMedianMs = perfMedian(ttft[kind])
if k.DraftN > 0 {
r := float64(k.DraftAccepted) / float64(k.DraftN)
k.DraftRate = &r
}
}
if sumTotal > 0 {
r := float64(sumCached) / float64(sumTotal)
+7
View File
@@ -249,6 +249,13 @@ func TestPerfSummarize(t *testing.T) {
if s.DraftRate == nil || *s.DraftRate != 0.75 {
t.Fatalf("acceptation : %v", s.DraftRate)
}
// Par nature aussi : le sous-agent sans brouillon n'a pas de taux inventé.
if m.DraftRate == nil || *m.DraftRate != 0.75 || m.DraftN != 40 || m.DraftAccepted != 30 {
t.Fatalf("acceptation main : %+v", m)
}
if sa := s.Kinds[perfSubagent]; sa.DraftRate != nil || sa.DraftN != 0 {
t.Fatalf("acceptation sous-agent : %+v", sa)
}
if len(s.Depth) != 4 || s.Depth[0].PPMedianTPS == nil || *s.Depth[0].PPMedianTPS != 500 || s.Depth[2].TGMedianTPS == nil || *s.Depth[2].TGMedianTPS != 30 || s.Depth[3].N != 0 {
t.Fatalf("profondeurs : %+v", s.Depth)
}
+5 -3
View File
@@ -98,10 +98,12 @@ var configTemplate = []struct{ key, help string }{
{"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " +
"vide = défaut du moteur, ou 2048 d'office sur un hybride avec un moteur officiel antérieur à b10864"},
{"SPEC", "décodage spéculatif, sortie inchangée : off (défaut) / auto = tête MTP du modèle ou MODEL_DRAFT si --fit place " +
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé si SPEC≠off"},
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus / ngram = n-grammes du contexte " +
"(ngram-mod 24/48/64, sans brouillon, utile en mode code, à mesurer ; refusé avec des poids sur CPU sauf " +
"OP_OFFLOAD_MIN_BATCH ≥ 66) / mtp+ngram = les deux, n-grammes seuls si pas de tête MTP"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé avec SPEC=auto, mtp ou mtp+ngram"},
{"SPEC_N_MAX", "jetons anticipés par étape (--spec-draft-n-max) ; vide = défaut du moteur (3)"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp ou mtp+ngram"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_PRESERVE", "on/off = garder ou non la réflexion des tours passés dans le gabarit (--reasoning-preserve) ; " +
"vide = défaut du moteur (on depuis b10763). Sans REASONING_ECHO, Loki ne renvoie pas cette réflexion : off rend " +
+15 -7
View File
@@ -3486,7 +3486,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<option value="bf16">bf16 (numérique différente de f16)</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : « auto » et « MTP » écrivent la clé SPEC,
<!-- Décodage spéculatif : « auto », « MTP », « n-grammes du contexte » et
« MTP + n-grammes » écrivent la clé SPEC,
que Loki traduit au lancement (tête MTP détectée dans le fichier,
garde-fous VRAM) ; les autres types vont tels quels dans
--spec-type. « non » écrit SPEC=off. Le nombre de jetons anticipés
@@ -3497,6 +3498,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<option value="auto">auto (MTP si le modèle en a une et que la VRAM suit)</option>
<option value="ngram">n-grammes du contexte (expérimental · mode code, à mesurer)</option>
<option value="mtp+ngram">MTP + n-grammes (expérimental · à mesurer)</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (imposé)</option>
<option value="draft-eagle3">EAGLE-3</option>
@@ -6076,10 +6079,14 @@ function syncLoadModeSub(mode){
// « auto » et « MTP » passent par la clé SPEC : Loki cherche la tête MTP dans
// le fichier et pose les garde-fous au lancement. Les autres types restent des
// --spec-type d'EXTRA_ARGS, qui ont toujours le dernier mot côté serveur.
// « ngram » et « mtp+ngram » aussi : Loki écrit lui-même les bornes de
// ngram-mod et refuse là où les longs lots de vérification coûteraient cher.
function specFromKey(v){
v = String(v||'').trim().toLowerCase();
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : '';
if(v === 'ngram+mtp') v = 'mtp+ngram';
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : (v === 'ngram' || v === 'mtp+ngram') ? v : '';
}
const SPEC_KEY_VALUES = {'auto': 'auto', 'draft-mtp': 'mtp', 'ngram': 'ngram', 'mtp+ngram': 'mtp+ngram'};
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
@@ -6104,20 +6111,21 @@ function syncSpecRow(){
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
// SPEC=ngram n'a pas de brouillon : un --spec-draft-n-max le ferait refuser.
row.style.display = on && sel.value !== 'ngram' ? '' : 'none';
// Le brouillon ne sert qu'à SPEC (auto / MTP) ; un brouillon déjà posé reste
// visible pour qu'on puisse le retirer.
const dr = document.getElementById('s-draft-row');
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || sel.value === 'mtp+ngram' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
const viaKey = v === 'auto' || v === 'draft-mtp';
const viaKey = v in SPEC_KEY_VALUES;
// « non » écrit SPEC=off : explicite, et identique au défaut du serveur.
cfgWriteKey('SPEC', v === 'auto' ? 'auto' : v === 'draft-mtp' ? 'mtp' : v ? '' : 'off');
cfgWriteKey('SPEC', viaKey ? SPEC_KEY_VALUES[v] : v ? '' : 'off');
eaSetValued('--spec-type', viaKey ? '' : v);
if(!v){
if(!v || v === 'ngram'){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
+4 -1
View File
@@ -1260,7 +1260,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="bf16">bf16 (numérique différente de f16)</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : « auto » et « MTP » écrivent la clé SPEC,
<!-- Décodage spéculatif : « auto », « MTP », « n-grammes du contexte » et
« MTP + n-grammes » écrivent la clé SPEC,
que Loki traduit au lancement (tête MTP détectée dans le fichier,
garde-fous VRAM) ; les autres types vont tels quels dans
--spec-type. « non » écrit SPEC=off. Le nombre de jetons anticipés
@@ -1271,6 +1272,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<option value="auto">auto (MTP si le modèle en a une et que la VRAM suit)</option>
<option value="ngram">n-grammes du contexte (expérimental · mode code, à mesurer)</option>
<option value="mtp+ngram">MTP + n-grammes (expérimental · à mesurer)</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (imposé)</option>
<option value="draft-eagle3">EAGLE-3</option>
+11 -6
View File
@@ -1108,10 +1108,14 @@ function syncLoadModeSub(mode){
// « auto » et « MTP » passent par la clé SPEC : Loki cherche la tête MTP dans
// le fichier et pose les garde-fous au lancement. Les autres types restent des
// --spec-type d'EXTRA_ARGS, qui ont toujours le dernier mot côté serveur.
// « ngram » et « mtp+ngram » aussi : Loki écrit lui-même les bornes de
// ngram-mod et refuse là où les longs lots de vérification coûteraient cher.
function specFromKey(v){
v = String(v||'').trim().toLowerCase();
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : '';
if(v === 'ngram+mtp') v = 'mtp+ngram';
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : (v === 'ngram' || v === 'mtp+ngram') ? v : '';
}
const SPEC_KEY_VALUES = {'auto': 'auto', 'draft-mtp': 'mtp', 'ngram': 'ngram', 'mtp+ngram': 'mtp+ngram'};
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
@@ -1136,20 +1140,21 @@ function syncSpecRow(){
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
// SPEC=ngram n'a pas de brouillon : un --spec-draft-n-max le ferait refuser.
row.style.display = on && sel.value !== 'ngram' ? '' : 'none';
// Le brouillon ne sert qu'à SPEC (auto / MTP) ; un brouillon déjà posé reste
// visible pour qu'on puisse le retirer.
const dr = document.getElementById('s-draft-row');
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || sel.value === 'mtp+ngram' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
const viaKey = v === 'auto' || v === 'draft-mtp';
const viaKey = v in SPEC_KEY_VALUES;
// « non » écrit SPEC=off : explicite, et identique au défaut du serveur.
cfgWriteKey('SPEC', v === 'auto' ? 'auto' : v === 'draft-mtp' ? 'mtp' : v ? '' : 'off');
cfgWriteKey('SPEC', viaKey ? SPEC_KEY_VALUES[v] : v ? '' : 'off');
eaSetValued('--spec-type', viaKey ? '' : v);
if(!v){
if(!v || v === 'ngram'){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}