From 539e36ea49425c96530363eff02c00bfdc84c9dc Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 10:28:25 +0200 Subject: [PATCH] =?UTF-8?q?Sp=C3=A9culation=20:=20SPEC=3Dngram=20et=20mtp+?= =?UTF-8?q?ngram,=20n-grammes=20du=20contexte=20en=20opt-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 30 +++ internal/loki/backend_serve.go | 2 +- internal/loki/backend_serve_fidelity.go | 12 +- internal/loki/backend_serve_spec.go | 286 +++++++++++++++++++---- internal/loki/backend_serve_spec_test.go | 161 +++++++++++++ internal/loki/perf_log.go | 12 + internal/loki/perf_log_test.go | 7 + internal/loki/sys_service.go | 8 +- internal/loki/ui/index.html | 22 +- internal/loki/ui/src/index.tmpl.html | 5 +- internal/loki/ui/src/js/07-models.js | 17 +- 11 files changed, 495 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 39f1c52..1cdce61 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,36 @@ Ajoutées par ce fork : exactement les jetons gardés (gabarit qui rend le dernier tour à l'identique), sinon recalcul comme avant. Ce que voit le modèle ne change pas : llama.cpp ne reprend un état que sur un préfixe de jetons identique. +- **Spéculation par n-grammes** (clé `SPEC`, valeurs `ngram` et `mtp+ngram`, + **off** par défaut, `loki config set SPEC ngram`, moteur relancé) : 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` y cherche la suite des + 24 derniers jetons et propose d'un coup les 48 à 64 suivants + (`--spec-type ngram-mod --spec-ngram-mod-n-match 24 --spec-ngram-mod-n-min 48 + --spec-ngram-mod-n-max 64`, toujours en clair, jamais `--spec-default` dont + le contenu peut changer d'une version à l'autre) ; `mtp+ngram` ajoute la tête + MTP (`--spec-type draft-mtp,ngram-mod`), les n-grammes passant d'abord quand + ils trouvent. Chaque jeton reste tiré par le modèle et un jeton proposé n'est + gardé que s'il coïncide : même distribution, mais pas le même texte au bit + près (la vérification par lots n'emprunte pas les noyaux du décodage jeton + par jeton) — on compare des sessions rejouées, pas des diffs. Rien n'est posé + si le moteur ne connaît pas `--spec-ngram-mod-n-match`, si `EXTRA_ARGS` ou + `LLAMA_ARG_SPEC_TYPE` règlent déjà la spéculation (`--spec-type`, qui + s'additionne au lieu de remplacer, `--spec-default`, `--spec-ngram-*`, `-md`…), + ou, pour `ngram`, si un `--spec-draft-*` y figure. `mtp+ngram` sans tête MTP + (ni dans le fichier ni en `MODEL_DRAFT`) ou sans `draft-mtp` dans le moteur + retombe sur les n-grammes seuls, avec une note. Avec `ngram`, une tête MTP + présente reste inutilisée (le type explicite coupe le choix automatique) : la + note le dit. Poids sur CPU (`--n-cpu-moe`, `-ot …=CPU`, `NGL` partiel) : + refusé tant que `GGML_OP_OFFLOAD_MIN_BATCH` (clé `OP_OFFLOAD_MIN_BATCH`) ne + dépasse pas le lot de vérification de 65 jetons — sinon chaque brouillon + recopierait les experts vers le GPU ; `OP_OFFLOAD_MIN_BATCH=128` pour + l'essayer, en mesurant aussi les lectures disque si le modèle dépasse la RAM. + Sur un hybride, chaque brouillon copie l'état récurrent (point de reprise) + et le recharge s'il est rejeté. `/api/perf/summary` donne l'acceptation par + nature de requête (`kinds..draft_rate`) : à garder là où elle paie. + Les drapeaux d'acceptation synthétique (`--spec-synth-*`, « benchmarking + only »), que Loki ne pose jamais, déclenchent un avertissement au lancement. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 017e7da..5beaa4b 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -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ù diff --git a/internal/loki/backend_serve_fidelity.go b/internal/loki/backend_serve_fidelity.go index a2cc92c..92e7350 100644 --- a/internal/loki/backend_serve_fidelity.go +++ b/internal/loki/backend_serve_fidelity.go @@ -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 } diff --git a/internal/loki/backend_serve_spec.go b/internal/loki/backend_serve_spec.go index 8324774..6304bce 100644 --- a/internal/loki/backend_serve_spec.go +++ b/internal/loki/backend_serve_spec.go @@ -22,6 +22,8 @@ import ( // MODEL_DRAFT= → la tête ou le brouillon, résolu comme MMPROJ // SPEC_N_MAX= → --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 diff --git a/internal/loki/backend_serve_spec_test.go b/internal/loki/backend_serve_spec_test.go index 4ff7326..a3a28d4 100644 --- a/internal/loki/backend_serve_spec_test.go +++ b/internal/loki/backend_serve_spec_test.go @@ -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) + } +} diff --git a/internal/loki/perf_log.go b/internal/loki/perf_log.go index 8f8ccd1..36aee20 100644 --- a/internal/loki/perf_log.go +++ b/internal/loki/perf_log.go @@ -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) diff --git a/internal/loki/perf_log_test.go b/internal/loki/perf_log_test.go index b7c7d24..3a9a06c 100644 --- a/internal/loki/perf_log_test.go +++ b/internal/loki/perf_log_test.go @@ -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) } diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 35eba0c..6db5cd5 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -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 " + diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index bd30687..5b3e518 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3486,7 +3486,8 @@ html[data-files="1"] #files-btn{color:var(--accent)} -