mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Placement : SPLIT_MODE=tensor, parallélisme de tenseurs entre cartes, en opt-in
En découpe par couches (défaut), chaque carte traverse ses couches à son
tour. En mode tensor, chaque couche est coupée entre les cartes, qui
additionnent ensuite leurs morceaux : décodage plus rapide d'après
l'amont, prefill nettement plus lent. Sur deux GeForce en PCIe et une
boucle d'agent qui relit beaucoup, rien n'est acquis : expérimental,
off par défaut, et une longue liste de refus propres plutôt qu'un
moteur qui meurt en boucle. Sans la clé, ligne et environnement
identiques à l'octet près (test).
- détection : la liste littérale {none,layer,row,tensor} dans l'aide du
moteur — « tensor » seul y est partout (--tensor-split) ; absente =
aucun compagnon posé, note au journal
- GGML_CUDA_ALLREDUCE=internal sauf choix de l'environnement : NCCL
compresse en BF16 les réductions du prefill (avec perte) ; nccl
gardé s'il est posé, avec un avertissement ; note NCCL indisponible /
P2P à poser soi-même
- -ngl all, -ts au prorata de la VRAM totale (--list-devices, ordre du
moteur, --device compris), -fa on : chacun seulement si EXTRA_ARGS ou
une LLAMA_ARG_* ne l'a pas déjà ; CTX explicite exigé
- refus : KV autre que f16/bf16/f32 (KV_TYPE*, -ctk/-ctv, LLAMA_ARG_*,
jamais réécrit), --backend-sampling, -fa off, -sm à la main, poids ou
KV sur CPU, NGL partiel, SPEC, SIDE_SLOT, une seule carte ou non CUDA,
architectures exclues par llama.cpp et qwen4exp, preset externe
- pas de --fit en mode tensor : estimation poids + cache de CTX jetons
contre 90 % de la VRAM, refus au-delà, CTX jamais réduit
- files CUDA, CUDA_GRAPH_OPT et FIT_TARGET voient le -sm tensor ;
SLOT_PERSIST refusé avec la clé ; diagnostic du journal pour les
échecs propres au mode tensor
- README et configTemplate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
539e36ea49
commit
5954d75118
7 files changed
+635
-5
No files matched your search
@@ -561,6 +561,29 @@ Ajoutées par ce fork :
|
||||
nature de requête (`kinds.<nature>.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.
|
||||
- **Parallélisme de tenseurs** (clé `SPLIT_MODE=tensor`, **off** par défaut,
|
||||
expérimental, moteur relancé) : au lieu de donner à chaque carte des couches
|
||||
entières (`-sm layer`, défaut), chaque couche est coupée entre les cartes,
|
||||
qui additionnent ensuite leurs morceaux. L'amont mesure un décodage plus
|
||||
rapide mais un prefill nettement plus lent : sur deux GeForce en PCIe et une
|
||||
boucle d'agent qui relit beaucoup, à mesurer par tour avant de l'adopter.
|
||||
Loki n'active le mode que si l'aide du moteur liste `{none,layer,row,tensor}`
|
||||
(le mot « tensor » seul est partout), et ajoute alors `-ngl all`, `-ts` au
|
||||
prorata de la VRAM totale des cartes dans l'ordre du moteur (`--device`
|
||||
compris), `-fa on` — chacun seulement s'il n'est pas déjà dans `EXTRA_ARGS`
|
||||
ou une `LLAMA_ARG_*` — et `GGML_CUDA_ALLREDUCE=internal` si l'environnement
|
||||
ne le fixe pas : le chemin NCCL compresse en BF16 les grosses réductions du
|
||||
prefill, une perte de précision ; la réduction interne garde le type natif.
|
||||
« NCCL not compiled in » au journal est alors sans effet ; `GGML_CUDA_P2P=1`
|
||||
reste à poser soi-même si le pilote le permet. Pas de `--fit` en mode
|
||||
tensor : Loki estime poids + cache de `CTX` jetons et refuse au-delà de 90 %
|
||||
de la VRAM — jamais en réduisant `CTX`. Refusé aussi (découpe par couches,
|
||||
note au journal) avec un cache KV autre que f16/bf16/f32 (jamais réécrit
|
||||
d'office), des poids ou le cache KV sur CPU (`--n-cpu-moe`, `-ot`, `NGL`
|
||||
partiel, `-nkvo`), `--backend-sampling`, `-fa off`, un `-sm` déjà réglé,
|
||||
`SPEC`, `SIDE_SLOT`, `CTX` non chiffré, une seule carte ou une carte non
|
||||
CUDA, une architecture que llama.cpp exclut (et `qwen4exp`), un preset
|
||||
externe ; `SLOT_PERSIST` est refusé avec la clé.
|
||||
- **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/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -346,6 +346,8 @@ func cmdServe(args []string) error {
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
probeCkptEnv(&si)
|
||||
probeSideSlotEnv(cfg, &si)
|
||||
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
|
||||
probeSplit(cfg, bin, &si)
|
||||
spec := specMode(cfg)
|
||||
if spec != "off" && spec != "" {
|
||||
probeSpec(cfg, &si)
|
||||
@@ -455,6 +457,10 @@ type serveSysInfo struct {
|
||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
||||
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||
|
||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||
// SPLIT_MODE=tensor (voir backend_serve_split.go) ; vide = inconnues.
|
||||
SplitDevs []splitDev
|
||||
}
|
||||
|
||||
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
|
||||
@@ -516,16 +522,30 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
notes = append(notes, loadNote)
|
||||
}
|
||||
|
||||
// Parallélisme de tenseurs (SPLIT_MODE=tensor, voir backend_serve_split.go) :
|
||||
// sans la clé, splitOn est faux et rien de ce qui suit ne change. Accepté, il
|
||||
// compte pour les réglages qui lisent le placement dans EXTRA_ARGS (files de
|
||||
// lancement, graphes CUDA, --fit) comme un -sm tensor écrit à la main.
|
||||
splitOn, splitA, splitNGLArgs, splitEnv, splitNotes := splitTensorArgs(cfg, extra, si)
|
||||
place := extra
|
||||
if splitOn {
|
||||
place = append(append([]string{}, extra...), "-sm", "tensor")
|
||||
}
|
||||
for k, v := range splitEnv {
|
||||
env[k] = v
|
||||
}
|
||||
notes = append(notes, splitNotes...)
|
||||
|
||||
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
|
||||
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
|
||||
q, qNotes := launchQueuesEnv(cfg, extra, si)
|
||||
q, qNotes := launchQueuesEnv(cfg, place, si)
|
||||
if q != "" {
|
||||
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
|
||||
}
|
||||
notes = append(notes, qNotes...)
|
||||
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
|
||||
// absents du preset, ils ne posent rien.
|
||||
g, gNotes := graphOptEnv(cfg, extra, si)
|
||||
g, gNotes := graphOptEnv(cfg, place, si)
|
||||
if g != "" {
|
||||
env["GGML_CUDA_GRAPH_OPT"] = g
|
||||
}
|
||||
@@ -595,7 +615,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
|
||||
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
|
||||
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
|
||||
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
if splitOn {
|
||||
args = append(args, splitNGLArgs...) // « -ngl all », ou rien si EXTRA_ARGS l'a déjà
|
||||
} else if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
|
||||
if note != "" {
|
||||
notes = append(notes, note)
|
||||
@@ -604,8 +626,8 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
}
|
||||
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
|
||||
// fitTargetArgs) : seulement si --fit tournera vraiment.
|
||||
fitt, fitNotes := fitTargetArgs(cfg, extra, si)
|
||||
args = append(args, fitt...)
|
||||
fitt, fitNotes := fitTargetArgs(cfg, place, si)
|
||||
args = append(append(args, fitt...), splitA...)
|
||||
notes = append(notes, fitNotes...)
|
||||
if ktv != "" {
|
||||
args = append(args, "-ctk", ktv)
|
||||
|
||||
@@ -84,6 +84,8 @@ func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok
|
||||
return false, "dossier des slots indisponible"
|
||||
case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "":
|
||||
return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon"
|
||||
case splitModeKey(cfg) != "":
|
||||
return false, "SPLIT_MODE : état réparti entre cartes, sauvegarde non validée"
|
||||
}
|
||||
if !slotSaveSafe(cfg, extra, si) {
|
||||
return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)"
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Parallélisme de tenseurs (SPLIT_MODE=tensor, off par défaut) : expérimental.
|
||||
//
|
||||
// La découpe par couches (défaut de llama.cpp) donne à chaque carte des
|
||||
// couches entières, traversées l'une après l'autre. En mode tensor, chaque
|
||||
// couche est coupée entre les cartes, qui calculent en même temps puis
|
||||
// additionnent leurs morceaux (all-reduce). Sur deux RTX 4090, l'amont mesure
|
||||
// +21-30 % au décodage, mais -37 % au prefill — et la boucle d'agent de Loki
|
||||
// relit beaucoup. Sur deux GeForce en PCIe sans P2P, la somme passe par
|
||||
// l'hôte : un gain net par tour est loin d'être acquis. D'où l'opt-in, et une
|
||||
// longue liste de refus propres plutôt qu'un moteur qui meurt en boucle.
|
||||
//
|
||||
// Fidélité : l'all-reduce change l'ordre des additions (bruit de virgule
|
||||
// flottante), mais le chemin NCCL va plus loin — au-delà de 32 768 éléments il
|
||||
// COMPRESSE les sommes partielles en BF16 (ggml-cuda.cu), soit à chaque couche
|
||||
// de chaque prefill : une perte de précision du calcul, de la classe d'une
|
||||
// quantification. Loki pose donc GGML_CUDA_ALLREDUCE=internal (réduction dans
|
||||
// le type natif), sauf si l'utilisateur a choisi lui-même. Le cache KV doit
|
||||
// rester f16, bf16 ou f32 : le mode tensor ne gère pas les caches quantifiés,
|
||||
// et Loki ne réécrit jamais un type de cache d'office (ça change le budget
|
||||
// VRAM). Pas de --fit en mode tensor (common/fit.cpp) : Loki estime la VRAM
|
||||
// lui-même et refuse si le compte ne tient pas, sans jamais réduire CTX.
|
||||
|
||||
// splitDev : une carte telle que la voit CE moteur (--list-devices), dans son
|
||||
// ordre — celui que suivent -ts et --device.
|
||||
type splitDev struct {
|
||||
ID string // CUDA0, Vulkan1…
|
||||
TotalMiB int64
|
||||
}
|
||||
|
||||
// splitTensorDenyArch : les architectures que llama.cpp refuse en mode tensor
|
||||
// (llm_arch_supports_sm_tensor) — il mourrait au chargement, et systemd
|
||||
// relancerait en boucle. qwen4exp n'y est pas, mais Loki l'écarte : son preset
|
||||
// place les experts sur CPU avec un cache q8_0, l'inverse de ce qu'exige le
|
||||
// mode tensor.
|
||||
var splitTensorDenyArch = map[string]bool{
|
||||
"grok": true, "mpt": true, "plamo2": true, "minicpm3": true, "gemma3n": true, "mamba": true, "mamba2": true,
|
||||
"jamba": true, "falcon-h1": true, "olmo2": true, "olmoe": true, "deepseek2": true, "deepseek32": true,
|
||||
"hy_v4": true, "dots3note": true, "glm-dsa": true, "bitnet": true, "t5": true, "nemotron_h": true,
|
||||
"nemotron_h_moe": true, "granitehybrid": true, "minimax-01": true, "minimax-m2": true, "minimax-m3": true,
|
||||
"mistral4": true, "kimi-linear": true, "bailingmoe3": true, "kimi-k3": true, "glm5-next": true,
|
||||
"qwen3tts": true, "qwen4exp": true,
|
||||
}
|
||||
|
||||
// splitArgEnv : les variables qui décident de ce que SPLIT_MODE règlerait.
|
||||
var splitArgEnv = []string{"LLAMA_ARG_FLASH_ATTN", "LLAMA_ARG_BACKEND_SAMPLING", "LLAMA_ARG_TENSOR_SPLIT"}
|
||||
|
||||
// splitModeKey lit SPLIT_MODE : "" (clé absente, off ou layer — le défaut du
|
||||
// moteur, rien à poser), "tensor", ou "?" si illisible.
|
||||
func splitModeKey(cfg map[string]string) string {
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPLIT_MODE"])); v {
|
||||
case "", "off", "layer", "non", "no", "0":
|
||||
return ""
|
||||
case "tensor":
|
||||
return v
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// helpSupportsSplitTensor : la liste LITTÉRALE des valeurs de -sm. Le mot
|
||||
// « tensor » seul est partout dans l'aide (--tensor-split, --override-tensor) :
|
||||
// s'y fier ferait passer -sm tensor à un moteur qui le refuse au démarrage.
|
||||
func helpSupportsSplitTensor(help string) bool {
|
||||
return strings.Contains(help, "{none,layer,row,tensor}")
|
||||
}
|
||||
|
||||
// probeSplit : seulement si SPLIT_MODE=tensor et que le moteur le propose —
|
||||
// sans la clé, rien n'est lu ni lancé. --list-devices tourne dans
|
||||
// l'environnement du vrai lancement (bibliothèques, CUDA_VISIBLE_DEVICES et
|
||||
// PCI_BUS_ID déjà posés par cmdServe) : même ordre de cartes que le moteur.
|
||||
func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) {
|
||||
if splitModeKey(cfg) != "tensor" || !helpSupportsSplitTensor(si.Help) {
|
||||
return
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range splitArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if si.UserEnv == nil {
|
||||
si.UserEnv = map[string]string{}
|
||||
}
|
||||
if v := os.Getenv("GGML_CUDA_ALLREDUCE"); v != "" {
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = v
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")).CombinedOutput()
|
||||
if err != nil {
|
||||
return // sortie tronquée (carte pleine, moteur planté) : on ne s'y fie pas
|
||||
}
|
||||
for _, d := range parseListDevices(string(out)) {
|
||||
id, _ := d["id"].(string)
|
||||
total, _ := d["total_mib"].(int)
|
||||
si.SplitDevs = append(si.SplitDevs, splitDev{ID: id, TotalMiB: int64(total)})
|
||||
}
|
||||
}
|
||||
|
||||
// splitPlan décide de SPLIT_MODE=tensor pour ce lancement. Fonction pure.
|
||||
//
|
||||
// on : accepté ; la ligne gagne -sm tensor et ses compagnons
|
||||
// why : pourquoi refusé (vide = clé absente)
|
||||
// devs : les cartes du découpage, dans l'ordre du moteur
|
||||
// needMiB : la VRAM estimée (poids + cache de CTX jetons)
|
||||
func splitPlan(cfg map[string]string, extra []string, si serveSysInfo) (on bool, why string, devs []splitDev, needMiB int64) {
|
||||
switch splitModeKey(cfg) {
|
||||
case "":
|
||||
return false, "", nil, 0
|
||||
case "?":
|
||||
return false, "SPLIT_MODE=" + strings.TrimSpace(cfg["SPLIT_MODE"]) + " illisible (tensor ou off)", nil, 0
|
||||
}
|
||||
refuse := func(why string) (bool, string, []splitDev, int64) { return false, why, nil, 0 }
|
||||
if isExternalConfig(cfg) {
|
||||
return refuse("preset externe")
|
||||
}
|
||||
if !helpSupportsSplitTensor(si.Help) {
|
||||
return refuse("ce moteur ne propose pas -sm tensor")
|
||||
}
|
||||
if sm := flagValue(extra, "-sm", "--split-mode"); sm != "" {
|
||||
return refuse("-sm " + sm + " déjà dans EXTRA_ARGS")
|
||||
}
|
||||
if si.ArgEnv["LLAMA_ARG_SPLIT_MODE"] != "" {
|
||||
return refuse("LLAMA_ARG_SPLIT_MODE déjà posée")
|
||||
}
|
||||
fa := flagValue(extra, "-fa", "--flash-attn")
|
||||
if fa == "" {
|
||||
fa = si.ArgEnv["LLAMA_ARG_FLASH_ATTN"]
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(fa)) {
|
||||
case "off", "disabled", "false", "0":
|
||||
return refuse("Flash Attention coupée (-fa " + fa + ") : le mode tensor l'exige")
|
||||
}
|
||||
if hasAnyFlag(extra, "-bs", "--backend-sampling") || envTruthy(si.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"]) {
|
||||
return refuse("échantillonnage sur GPU (--backend-sampling) non pris en charge en mode tensor")
|
||||
}
|
||||
k, v, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
if !splitKVOK(k) || !splitKVOK(v) {
|
||||
return refuse(fmt.Sprintf("cache KV %s/%s : le mode tensor n'accepte que f16, bf16 ou f32 "+
|
||||
"(jamais réécrit d'office : la VRAM changerait)", kvName(k), kvName(v)))
|
||||
}
|
||||
if r := tensorOverride(extra, si.ArgEnv); r != "" {
|
||||
return refuse(r + " : le mode tensor met tout sur les cartes")
|
||||
}
|
||||
if !kvOffloaded(extra, si.ArgEnv) {
|
||||
return refuse("cache KV sur CPU")
|
||||
}
|
||||
switch {
|
||||
case si.GGUF == nil || si.ModelBytes <= 0:
|
||||
return refuse("métadonnées ou taille du modèle illisibles")
|
||||
case splitTensorDenyArch[strings.ToLower(si.GGUF.Arch)]:
|
||||
return refuse("architecture " + si.GGUF.Arch + " exclue du mode tensor")
|
||||
}
|
||||
if _, why := splitNGL(cfg, extra, si.GGUF.BlockCount); why != "" {
|
||||
return refuse(why)
|
||||
}
|
||||
ctxS, ok := ctxFixed(cfg, extra)
|
||||
if !ok {
|
||||
return refuse("contexte « " + ctxS + " » non chiffré : pas de --fit en mode tensor, donne un CTX explicite")
|
||||
}
|
||||
if sideSlotOn(cfg) {
|
||||
return refuse("SIDE_SLOT : cache doublé, sans --fit pour rattraper")
|
||||
}
|
||||
if specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) {
|
||||
return refuse("décodage spéculatif : brouillon et vérification par lots non validés en mode tensor")
|
||||
}
|
||||
devs, why = splitDevices(extra, si)
|
||||
if why != "" {
|
||||
return refuse(why)
|
||||
}
|
||||
// VRAM : poids + cache de CTX jetons (et état récurrent) contre 90 % des
|
||||
// cartes, la marge de SIDE_SLOT. Les tampons de calcul n'y sont pas : c'est
|
||||
// un minimum, pas une promesse — le journal dira le reste.
|
||||
ctx, _ := strconv.Atoi(ctxS)
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, k, v)
|
||||
if perTok <= 0 {
|
||||
return refuse("taille du cache KV inconnue pour ce modèle")
|
||||
}
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
total += d.TotalMiB
|
||||
}
|
||||
mm := int64(0)
|
||||
if si.MMProj != "" {
|
||||
if st, err := os.Stat(si.MMProj); err == nil {
|
||||
mm = st.Size()
|
||||
}
|
||||
}
|
||||
need := float64(si.ModelBytes+mm) + perTok*float64(ctx) + float64(rec)
|
||||
needMiB = int64(math.Ceil(need / (1 << 20)))
|
||||
if need > 0.9*float64(total)*(1<<20) {
|
||||
return refuse(fmt.Sprintf("VRAM insuffisante sans --fit : ~%d Mio (poids + cache de %d jetons) pour %d Mio "+
|
||||
"sur %d cartes — CTX n'est jamais réduit d'office", needMiB, ctx, total, len(devs)))
|
||||
}
|
||||
return true, "", devs, needMiB
|
||||
}
|
||||
|
||||
func splitKVOK(t string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "", "f16", "bf16", "f32":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kvName(t string) string {
|
||||
if t == "" {
|
||||
return "f16"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// splitNGL : les couches GPU en mode tensor, où tout doit être sur les cartes.
|
||||
// Un -ngl d'EXTRA_ARGS reste celui de l'utilisateur (rien à ajouter) s'il dit
|
||||
// « tout » ; sinon NGL est traduit en « -ngl all » (999, auto et la clé absente
|
||||
// visent déjà le tout-GPU, mais auto ne fait rien sans --fit). Une valeur qui
|
||||
// laisse des couches au CPU est refusée.
|
||||
func splitNGL(cfg map[string]string, extra []string, blocks int) (args []string, why string) {
|
||||
all := func(v string) bool {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
if v == "all" || v == "999" {
|
||||
return true
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
return err == nil && blocks > 0 && n >= blocks+1
|
||||
}
|
||||
if v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"); v != "" {
|
||||
if all(v) {
|
||||
return nil, ""
|
||||
}
|
||||
return nil, "-ngl " + v + " dans EXTRA_ARGS : le mode tensor veut toutes les couches sur les cartes (all)"
|
||||
}
|
||||
switch v := strings.TrimSpace(cfg["NGL"]); {
|
||||
case v == "" || strings.EqualFold(v, "auto") || all(v):
|
||||
return []string{"-ngl", "all"}, ""
|
||||
default:
|
||||
return nil, "NGL=" + v + " : le mode tensor veut toutes les couches sur les cartes (all)"
|
||||
}
|
||||
}
|
||||
|
||||
// splitDevices : les cartes du découpage. --device (EXTRA_ARGS ou variable)
|
||||
// choisit lesquelles, dans son ordre ; sinon toutes celles que liste le
|
||||
// moteur. Toutes CUDA (l'all-reduce n'existe qu'entre cartes CUDA), deux au
|
||||
// moins.
|
||||
func splitDevices(extra []string, si serveSysInfo) ([]splitDev, string) {
|
||||
if len(si.SplitDevs) == 0 {
|
||||
return nil, "cartes inconnues (--list-devices n'a rien répondu)"
|
||||
}
|
||||
devs := si.SplitDevs
|
||||
sel := flagValue(extra, "-dev", "--device")
|
||||
if sel == "" {
|
||||
sel = si.ArgEnv["LLAMA_ARG_DEVICE"]
|
||||
}
|
||||
if sel = strings.TrimSpace(sel); sel != "" {
|
||||
devs = nil
|
||||
for _, name := range strings.Split(sel, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
found := false
|
||||
for _, d := range si.SplitDevs {
|
||||
if strings.EqualFold(d.ID, name) {
|
||||
devs, found = append(devs, d), true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, "--device " + name + " inconnu de ce moteur"
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range devs {
|
||||
if !strings.HasPrefix(strings.ToUpper(d.ID), "CUDA") {
|
||||
return nil, d.ID + " n'est pas une carte CUDA"
|
||||
}
|
||||
}
|
||||
if len(devs) < 2 {
|
||||
return nil, "une seule carte"
|
||||
}
|
||||
return devs, ""
|
||||
}
|
||||
|
||||
// splitTS : la répartition par défaut, au prorata de la VRAM TOTALE de chaque
|
||||
// carte (la libre varie avec ce qui tourne encore au lancement), en pour cent.
|
||||
func splitTS(devs []splitDev) string {
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
total += d.TotalMiB
|
||||
}
|
||||
if total <= 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(devs))
|
||||
for i, d := range devs {
|
||||
parts[i] = strconv.FormatInt(int64(math.Round(100*float64(d.TotalMiB)/float64(total))), 10)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// splitTensorArgs : ce que SPLIT_MODE=tensor ajoute à la ligne, à la place du -ngl
|
||||
// habituel (nglOut), l'environnement et les notes. on=false : rien, et la
|
||||
// ligne reste celle d'avant à l'octet près.
|
||||
func splitTensorArgs(cfg map[string]string, extra []string, si serveSysInfo) (on bool, args, ngl []string, env map[string]string, notes []string) {
|
||||
ok, why, devs, needMiB := splitPlan(cfg, extra, si)
|
||||
if !ok {
|
||||
if why != "" {
|
||||
notes = append(notes, "SPLIT_MODE=tensor refusé ("+why+") : découpe par couches, comme sans la clé")
|
||||
}
|
||||
return false, nil, nil, nil, notes
|
||||
}
|
||||
ngl, _ = splitNGL(cfg, extra, si.GGUF.BlockCount)
|
||||
args = []string{"-sm", "tensor"}
|
||||
ts := ""
|
||||
if !hasAnyFlag(extra, "-ts", "--tensor-split") && si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] == "" {
|
||||
if ts = splitTS(devs); ts != "" {
|
||||
args = append(args, "-ts", ts)
|
||||
}
|
||||
}
|
||||
// llama.cpp active de lui-même Flash Attention en mode tensor ; l'écrire
|
||||
// le rend lisible dans le journal. Jamais en double d'un -fa de l'utilisateur.
|
||||
if !hasAnyFlag(extra, "-fa", "--flash-attn") && si.ArgEnv["LLAMA_ARG_FLASH_ATTN"] == "" {
|
||||
args = append(args, "-fa", "on")
|
||||
}
|
||||
env = map[string]string{}
|
||||
note := fmt.Sprintf("SPLIT_MODE=tensor (expérimental) : -sm tensor sur %d cartes", len(devs))
|
||||
if ts != "" {
|
||||
note += ", -ts " + ts + " (VRAM totale)"
|
||||
}
|
||||
note += fmt.Sprintf(", ~%d Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour", needMiB)
|
||||
switch ar := strings.ToLower(strings.TrimSpace(si.UserEnv["GGML_CUDA_ALLREDUCE"])); ar {
|
||||
case "":
|
||||
env["GGML_CUDA_ALLREDUCE"] = "internal"
|
||||
note += " ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). " +
|
||||
"« NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les " +
|
||||
"sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet"
|
||||
case "nccl":
|
||||
note += " ; avertissement : GGML_CUDA_ALLREDUCE=nccl (environnement) — NCCL compresse en BF16 les grosses " +
|
||||
"réductions du prefill : calcul avec perte. « NCCL not compiled in » au journal = repli sur la réduction interne"
|
||||
default:
|
||||
note += " ; GGML_CUDA_ALLREDUCE=" + ar + " (environnement) gardé"
|
||||
}
|
||||
if si.GGUF.Hybrid {
|
||||
note += " ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont"
|
||||
}
|
||||
notes = append(notes, note)
|
||||
return true, args, ngl, env, notes
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const helpTensor = helpRecent + `-sm, --split-mode {none,layer,row,tensor}
|
||||
how to split the model across multiple GPUs
|
||||
-ts, --tensor-split N0,N1,N2,... fraction of the model to offload to each GPU
|
||||
`
|
||||
|
||||
// tensorReady : un hybride 64 couches de 16 Gio sur 16 + 12 Gio de VRAM, sur
|
||||
// un moteur qui propose -sm tensor. Le cas où SPLIT_MODE=tensor est accepté.
|
||||
func tensorReady() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpTensor,
|
||||
Model: "/models/Qwen3.6-27B.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 64, KeyLen: 256, ValLen: 256, HeadCountKV: 4,
|
||||
Hybrid: true, FullAttnInterval: 4},
|
||||
ModelBytes: 16 << 30,
|
||||
ArgEnv: map[string]string{},
|
||||
UserEnv: map[string]string{},
|
||||
SplitDevs: []splitDev{{ID: "CUDA0", TotalMiB: 16311}, {ID: "CUDA1", TotalMiB: 12288}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTensorPlan(t *testing.T) {
|
||||
tensor := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536"}
|
||||
with := func(kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for k, v := range tensor {
|
||||
m[k] = v
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
on bool
|
||||
why string // fragment attendu de la raison du refus
|
||||
}{
|
||||
{name: "clé absente : rien, en silence", cfg: map[string]string{}},
|
||||
{name: "SPLIT_MODE=layer : le défaut, rien", cfg: map[string]string{"SPLIT_MODE": "layer"}},
|
||||
{name: "accepté", cfg: tensor, on: true},
|
||||
{name: "illisible", cfg: with("SPLIT_MODE", "rows"), why: "illisible"},
|
||||
{name: "aide qui ne cite que --tensor-split : refusé",
|
||||
cfg: tensor, why: "-sm tensor", si: func(s *serveSysInfo) { s.Help = helpRecent + "-ts, --tensor-split N0,N1\n" }},
|
||||
{name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"},
|
||||
{name: "-sm dans EXTRA_ARGS : l'utilisateur garde la main", cfg: tensor, extra: []string{"-sm", "layer"}, why: "-sm layer"},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE", cfg: tensor, why: "LLAMA_ARG_SPLIT_MODE",
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPLIT_MODE"] = "row" }},
|
||||
{name: "-fa off : refusé", cfg: tensor, extra: []string{"-fa", "off"}, why: "Flash Attention"},
|
||||
{name: "-fa on : accepté", cfg: tensor, extra: []string{"-fa", "on"}, on: true},
|
||||
{name: "--backend-sampling : refusé", cfg: tensor, extra: []string{"--backend-sampling"}, why: "backend-sampling"},
|
||||
{name: "LLAMA_ARG_BACKEND_SAMPLING : refusé", cfg: tensor, why: "backend-sampling",
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"] = "1" }},
|
||||
{name: "KV_TYPE=q8_0 : refusé, jamais réécrit", cfg: with("KV_TYPE", "q8_0"), why: "q8_0"},
|
||||
{name: "-ctk q8_0 dans EXTRA_ARGS (preset MoE) : refusé", cfg: tensor,
|
||||
extra: []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0"}, why: "q8_0"},
|
||||
{name: "KV_TYPE_V=q4_0 : refusé", cfg: with("KV_TYPE_V", "q4_0"), why: "q4_0"},
|
||||
{name: "KV bf16 : accepté", cfg: with("KV_TYPE", "bf16"), on: true},
|
||||
{name: "--n-cpu-moe : refusé", cfg: tensor, extra: []string{"--n-cpu-moe", "40"}, why: "n-cpu-moe"},
|
||||
{name: "-ot : refusé", cfg: tensor, extra: []string{"-ot", "exps=CPU"}, why: "surcharge"},
|
||||
{name: "cache KV sur CPU : refusé", cfg: tensor, extra: []string{"-nkvo"}, why: "KV sur CPU"},
|
||||
{name: "NGL=40 : refusé", cfg: with("NGL", "40"), why: "NGL=40"},
|
||||
{name: "NGL=999 : accepté", cfg: with("NGL", "999"), on: true},
|
||||
{name: "-ngl 99 dans EXTRA_ARGS (≥ toutes les couches) : accepté", cfg: tensor, extra: []string{"-ngl", "99"}, on: true},
|
||||
{name: "-ngl 30 dans EXTRA_ARGS : refusé", cfg: tensor, extra: []string{"-ngl", "30"}, why: "-ngl 30"},
|
||||
{name: "CTX=0 : refusé", cfg: with("CTX", "0"), why: "non chiffré"},
|
||||
{name: "VRAM insuffisante : refusé, CTX jamais réduit", cfg: with("CTX", "262144"), why: "VRAM insuffisante"},
|
||||
{name: "qwen4exp : refusé", cfg: tensor, why: "qwen4exp", si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
|
||||
{name: "mamba (refusé par llama.cpp) : refusé proprement", cfg: tensor, why: "mamba",
|
||||
si: func(s *serveSysInfo) { s.GGUF.Arch = "mamba" }},
|
||||
{name: "GGUF illisible", cfg: tensor, why: "illisibles", si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "SPEC=mtp : refusé", cfg: with("SPEC", "mtp"), why: "spéculatif"},
|
||||
{name: "SIDE_SLOT=on : refusé", cfg: with("SIDE_SLOT", "on"), why: "SIDE_SLOT"},
|
||||
{name: "une seule carte", cfg: tensor, why: "une seule carte",
|
||||
si: func(s *serveSysInfo) { s.SplitDevs = s.SplitDevs[:1] }},
|
||||
{name: "cartes inconnues", cfg: tensor, why: "inconnues", si: func(s *serveSysInfo) { s.SplitDevs = nil }},
|
||||
{name: "moteur Vulkan : refusé", cfg: tensor, why: "CUDA",
|
||||
si: func(s *serveSysInfo) { s.SplitDevs = []splitDev{{"Vulkan0", 16311}, {"Vulkan1", 12288}} }},
|
||||
{name: "--device d'une seule carte : refusé", cfg: tensor, extra: []string{"--device", "CUDA0"}, why: "une seule carte"},
|
||||
{name: "--device inconnu : refusé", cfg: tensor, extra: []string{"-dev", "CUDA0,CUDA7"}, why: "CUDA7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := tensorReady()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
on, why, _, _ := splitPlan(c.cfg, c.extra, si)
|
||||
if on != c.on {
|
||||
t.Fatalf("on = %v, attendu %v (%q)", on, c.on, why)
|
||||
}
|
||||
if !strings.Contains(why, c.why) || (c.why == "" && why != "") {
|
||||
t.Errorf("raison %q, attendu %q", why, c.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTensorArgs(t *testing.T) {
|
||||
const bin = "/opt/llama/llama-server"
|
||||
cfg := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536", "NGL": "999"}
|
||||
|
||||
// Clé absente : la ligne et l'environnement d'avant, à l'octet près, même
|
||||
// sur un moteur et une machine où le mode tensor serait possible.
|
||||
plain := map[string]string{"CTX": "65536", "NGL": "999"}
|
||||
got, env, _ := buildServeArgs(plain, nil, bin, tensorReady())
|
||||
si := tensorReady()
|
||||
si.Help, si.SplitDevs = helpRecent, nil
|
||||
want, wantEnv, _ := buildServeArgs(plain, nil, bin, si)
|
||||
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(env, wantEnv) {
|
||||
t.Fatalf("sans SPLIT_MODE : %q %v, attendu %q %v", got, env, want, wantEnv)
|
||||
}
|
||||
|
||||
got, env, notes := buildServeArgs(cfg, nil, bin, tensorReady())
|
||||
tail := []string{"-ngl", "all", "-sm", "tensor", "-ts", "57,43", "-fa", "on"}
|
||||
if i := slices.Index(got, "-ngl"); i < 0 || !reflect.DeepEqual(got[i:i+len(tail)], tail) {
|
||||
t.Fatalf("args = %q, attendu …%q…", got, tail)
|
||||
}
|
||||
if env["GGML_CUDA_ALLREDUCE"] != "internal" {
|
||||
t.Errorf("GGML_CUDA_ALLREDUCE = %q, attendu internal (NCCL compresse en BF16)", env["GGML_CUDA_ALLREDUCE"])
|
||||
}
|
||||
if len(notes) == 0 || !strings.Contains(strings.Join(notes, "\n"), "NCCL") {
|
||||
t.Errorf("la note doit parler de NCCL : %q", notes)
|
||||
}
|
||||
|
||||
// Les choix de l'utilisateur gagnent et ne sont jamais doublés.
|
||||
extra := []string{"-ts", "60,40", "-fa", "on", "-ngl", "all"}
|
||||
got, _, _ = buildServeArgs(cfg, extra, bin, tensorReady())
|
||||
for _, f := range []string{"-ts", "-fa", "-ngl", "-sm"} {
|
||||
if n := countFlag(got, f); n != 1 {
|
||||
t.Errorf("%s présent %d fois : %q", f, n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// --device dans l'ordre inverse : la répartition suit.
|
||||
got, _, _ = buildServeArgs(cfg, []string{"--device", "CUDA1,CUDA0"}, bin, tensorReady())
|
||||
if argValue(got, "-ts") != "43,57" {
|
||||
t.Errorf("-ts = %q, attendu 43,57", argValue(got, "-ts"))
|
||||
}
|
||||
|
||||
// GGML_CUDA_ALLREDUCE déjà posé : gardé, et NCCL signalé comme avec perte.
|
||||
si = tensorReady()
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = "nccl"
|
||||
_, env, notes = buildServeArgs(cfg, nil, bin, si)
|
||||
if _, set := env["GGML_CUDA_ALLREDUCE"]; set {
|
||||
t.Errorf("choix de l'utilisateur écrasé : %v", env)
|
||||
}
|
||||
if !strings.Contains(strings.Join(notes, "\n"), "BF16") {
|
||||
t.Errorf("NCCL doit être signalé : %q", notes)
|
||||
}
|
||||
|
||||
// Les réglages qui lisent le placement voient le mode tensor : pas de
|
||||
// graphes Q/K/V en parallèle, pas de --fit-target, pas de file CUDA.
|
||||
withOpts := map[string]string{"CUDA_GRAPH_OPT": "on", "FIT_TARGET": "1024"}
|
||||
for k, v := range cfg {
|
||||
withOpts[k] = v
|
||||
}
|
||||
got, env, _ = buildServeArgs(withOpts, nil, bin, tensorReady())
|
||||
if env["GGML_CUDA_GRAPH_OPT"] != "" || slices.Contains(got, "--fit-target") {
|
||||
t.Errorf("mode tensor : ni GGML_CUDA_GRAPH_OPT ni --fit-target, %q %v", got, env)
|
||||
}
|
||||
|
||||
// Moteur sans -sm tensor : aucun compagnon (-ngl all, -ts, -fa), une note.
|
||||
si = tensorReady()
|
||||
si.Help = helpRecent
|
||||
got, env, notes = buildServeArgs(cfg, nil, bin, si)
|
||||
if slices.Contains(got, "-sm") || slices.Contains(got, "-ts") || slices.Contains(got, "-fa") ||
|
||||
argValue(got, "-ngl") != "auto" || env["GGML_CUDA_ALLREDUCE"] != "" {
|
||||
t.Errorf("refusé : ligne d'avant attendue, %q %v", got, env)
|
||||
}
|
||||
if !strings.Contains(strings.Join(notes, "\n"), "SPLIT_MODE=tensor refusé") {
|
||||
t.Errorf("le refus doit se dire : %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func countFlag(args []string, f string) int {
|
||||
n := 0
|
||||
for _, a := range args {
|
||||
if a == f {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestSplitTensorSlotPersist(t *testing.T) {
|
||||
si := tensorReady()
|
||||
si.Help += "--slot-save-path PATH\n"
|
||||
si.SlotDir = "/data/slots"
|
||||
cfg := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1", "SPLIT_MODE": "tensor"}
|
||||
if ok, why := slotPersistPlan(cfg, nil, si); ok || !strings.Contains(why, "SPLIT_MODE") {
|
||||
t.Errorf("SLOT_PERSIST avec SPLIT_MODE : refusé attendu, got %v %q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpSupportsSplitTensor(t *testing.T) {
|
||||
if helpSupportsSplitTensor(helpRecent + "-ts, --tensor-split N0,N1 fraction\n--override-tensor\n") {
|
||||
t.Error("« tensor » ailleurs dans l'aide ne suffit pas")
|
||||
}
|
||||
if !helpSupportsSplitTensor(helpTensor) {
|
||||
t.Error("liste littérale de -sm : oui")
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,12 @@ var configTemplate = []struct{ key, help string }{
|
||||
"vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"},
|
||||
{"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " +
|
||||
"vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"},
|
||||
{"SPLIT_MODE", "tensor = parallélisme de tenseurs entre cartes CUDA (-sm tensor, expérimental) : décodage plus rapide, " +
|
||||
"prefill plus lent, à mesurer par tour. Loki pose -ngl all, -ts au prorata de la VRAM, -fa on (sauf réglés à la main) " +
|
||||
"et GGML_CUDA_ALLREDUCE=internal (NCCL compresse en BF16, avec perte). Refusé sans la liste {none,layer,row,tensor} " +
|
||||
"dans l'aide du moteur, avec un cache KV autre que f16/bf16/f32, des poids ou le KV sur CPU, --backend-sampling, " +
|
||||
"-fa off, -sm à la main, SPEC, SIDE_SLOT, CTX non chiffré, une seule carte ou une VRAM estimée insuffisante. " +
|
||||
"Vide/off (défaut) = découpe par couches"},
|
||||
{"CUDA_GRAPH_OPT", "on = GGML_CUDA_GRAPH_OPT=1, branches Q/K/V en parallèle au décodage (expérimental, " +
|
||||
"sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"},
|
||||
{"KV_TYPE", "quantization du cache KV ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " +
|
||||
|
||||
@@ -120,6 +120,10 @@ func modelLoadError() string {
|
||||
strings.Contains(low, "failed to initialize speculative decoding context"):
|
||||
// Avant l'échec générique : c'est le brouillon qui manque, pas le modèle.
|
||||
reason = "le décodage spéculatif n'a pas pu démarrer (tête MTP ou modèle brouillon) — SPEC=off dans le preset"
|
||||
case strings.Contains(low, "split_mode_tensor requires flash_attn"),
|
||||
strings.Contains(low, "split_mode_tensor not implemented"):
|
||||
// Les autres lignes qui citent SPLIT_MODE_TENSOR ne sont que des avis.
|
||||
reason = "le mode tensor (-sm tensor) n'a pas pu démarrer — SPLIT_MODE=off dans le preset"
|
||||
case strings.Contains(l, "has offset") && strings.Contains(l, "expected"):
|
||||
reason = "format de quantification non reconnu par ce moteur"
|
||||
case strings.Contains(low, "unknown model architecture"),
|
||||
|
||||
Reference in new issue
Block a user