diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 264d07c..0b57d5e 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -189,7 +189,10 @@ func safePresetPath(name string) (string, error) { // version effaçait la clé, et le moteur repartait sur le défaut « toutes les // interfaces » : une machine volontairement fermée se rouvrait toute seule au // premier changement de preset. -var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"} +// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la +// même logique : un « off » posé parce que CETTE machine bloque ne doit pas +// disparaître à la bascule suivante. +var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"} // softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit // pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un @@ -199,7 +202,8 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS // inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le // modèle se retrouvait entièrement sur une seule carte et mourait sur // « cudaMalloc failed: out of memory ». Le preset explicite gagne. -var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true} +// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle. +var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true} // applyPresetFile installe le preset comme configuration active, en réinjectant // les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être diff --git a/internal/loki/backend_presets_test.go b/internal/loki/backend_presets_test.go index f598194..b679466 100644 --- a/internal/loki/backend_presets_test.go +++ b/internal/loki/backend_presets_test.go @@ -47,7 +47,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) { } } // Configuration courante : un preset + les réglages machine. - setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n") + setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\nCUDA_LAUNCH_QUEUES=off\n") target := filepath.Join(home, "cible.env") write(target, "MODEL=nouveau.gguf\nCTX=8192\n") @@ -60,7 +60,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) { t.Fatalf("le preset n'a pas été appliqué : %v", cfg) } for k, want := range map[string]string{ - "CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always", + "CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always", "CUDA_LAUNCH_QUEUES": "off", } { if cfg[k] != want { t.Errorf("%s = %q après bascule, attendu %q (réglage machine effacé)", k, cfg[k], want) diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 9d86e2d..ae71109 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -336,7 +336,12 @@ func cmdServe(args []string) error { si.CPU = cpusetThreads(os.DirFS("/")) } - llmArgs, env, notes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), bin, si) + extra := splitArgs(cfg["EXTRA_ARGS"]) + // Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir + // launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse. + probeServeGPUs(cfg, extra, &si) + + llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si) applyServeEnv(env) for _, n := range notes { fmt.Fprintln(os.Stderr, "[loki serve] "+n) @@ -373,6 +378,10 @@ type serveSysInfo struct { MMProj string // chemin du projecteur vision, résolu et vérifié ; vide = pas de vision APIKey string // clé effective (.api_key, sinon config.env) ; vide = serveur ouvert CPU cpuBudget // sonde de conteneur (Linux) ; zéro = llama.cpp choisit ses threads seul + + LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché + GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé + ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline (voir serveArgEnv) } // cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par @@ -436,6 +445,14 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS // Threads : vide ou 0 = AUCUN drapeau, pour que llama.cpp prenne ses cœurs // physiques au lieu de tous les threads logiques (voir threadArgs). + // 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) + if q != "" { + env["CUDA_SCALE_LAUNCH_QUEUES"] = q + } + notes = append(notes, qNotes...) + threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU) notes = append(notes, threadNotes...) args = []string{bin, diff --git a/internal/loki/backend_serve_queues.go b/internal/loki/backend_serve_queues.go new file mode 100644 index 0000000..2dc7fda --- /dev/null +++ b/internal/loki/backend_serve_queues.go @@ -0,0 +1,201 @@ +package loki + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// Files de lancement CUDA (CUDA_SCALE_LAUNCH_QUEUES) sur deux GPU ou plus. +// +// Quand llama.cpp répartit les couches entre plusieurs cartes et que le modèle +// y tient en entier, il fait travailler les cartes en pipeline : pendant que la +// seconde calcule un micro-lot, la première attaque le suivant. Encore faut-il +// que le CPU puisse empiler assez de lancements CUDA d'avance ; avec la file par +// défaut du pilote, il bloque et le pipeline se vide. « 4x » quadruple cette +// file (PR llama.cpp #19042) : c'est une affaire de pilote, pas de calcul — mêmes +// noyaux, mêmes entrées, même ordre, donc une sortie identique au jeton près. Le +// prix : ~120 Mo de RAM système par GPU. +// +// llama.cpp l'a posé par défaut puis retiré (#19227) après des blocages sur +// Jetson (mémoire unifiée, MoE décodé en partie sur CPU) ; ses docs le +// recommandent désormais à la main en multi-GPU. Loki le pose donc lui-même, +// mais seulement là où le pipeline peut réellement exister (voir +// pipelineBlocker) : ailleurs le gain est nul et l'on ne s'exposerait qu'au +// seul cas de panne jamais rapporté. +// +// Gain non mesuré sur la machine de référence (5060 Ti + 3060, 27B) : en amont, +// +10 à 25 % de prompt seulement sur un 70B réparti sur deux grosses cartes, +// rien de mesurable sur un 8B. Le décodage ne bouge pas. Pour vérifier qu'il +// s'applique, le journal du moteur doit dire « pipeline parallelism enabled ». +// +// Choix de l'utilisateur, par ordre de priorité : +// +// CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement → intouché (conteneur Unraid…) +// CUDA_LAUNCH_QUEUES=off → rien +// CUDA_LAUNCH_QUEUES=0.25x|0.5x|2x|4x → cette valeur, sans condition +// vide ou auto → 4x si ≥ 2 GPU et pipeline possible +// +// Une autre valeur serait lue comme 1x par le pilote, sans un mot : on l'ignore +// en le disant. + +const launchQueuesAuto = "4x" + +// launchQueueValues : les seules valeurs que le pilote CUDA comprend. +var launchQueueValues = map[string]bool{"0.25x": true, "0.5x": true, "2x": true, "4x": true} + +// serveArgEnv : les variables LLAMA_ARG_* qui font, sans drapeau, ce que lit +// pipelineBlocker. llama.cpp les applique quand le drapeau manque — les ignorer +// ferait conclure à un pipeline que le moteur n'ouvrira pas. +var serveArgEnv = []string{"LLAMA_ARG_DEVICE", "LLAMA_ARG_SPLIT_MODE", "LLAMA_ARG_N_GPU_LAYERS", + "LLAMA_ARG_CPU_MOE", "LLAMA_ARG_N_CPU_MOE", "LLAMA_ARG_NO_KV_OFFLOAD"} + +// launchQueuesEnv décide de CUDA_SCALE_LAUNCH_QUEUES. Fonction pure : le nombre +// de GPU et l'environnement arrivent déjà sondés dans si. Valeur vide = ne rien +// poser. +func launchQueuesEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) { + if si.LaunchQueues != "" { + return "", nil + } + want := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"])) + switch { + case want == "off": + return "", nil + case launchQueueValues[want]: + return want, []string{"CUDA_SCALE_LAUNCH_QUEUES=" + want + " (CUDA_LAUNCH_QUEUES)"} + case want != "" && want != "auto": + notes = append(notes, "CUDA_LAUNCH_QUEUES="+cfg["CUDA_LAUNCH_QUEUES"]+ + " ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique") + } + n := servedGPUCount(extra, si) + if n < 2 || pipelineBlocker(cfg, extra, si.ArgEnv) != "" { + return "", notes + } + return launchQueuesAuto, append(notes, fmt.Sprintf("CUDA_SCALE_LAUNCH_QUEUES=%s (%d GPU, parallélisme pipeline "+ + "possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", launchQueuesAuto, n)) +} + +// servedGPUCount : combien de GPU CUDA llama-server utilisera. Le --device du +// preset d'abord (c'est là que l'éditeur range le choix des cartes), puis ce +// que cmdServe a compté (CUDA_VISIBLE_DEVICES, sinon nvidia-smi). Un --device +// qui ne nomme que des cartes Vulkan ou Metal compte zéro : la variable ne +// concerne que CUDA. +func servedGPUCount(extra []string, si serveSysInfo) int { + dev := flagValue(extra, "-dev", "--device") + if dev == "" { + dev = si.ArgEnv["LLAMA_ARG_DEVICE"] + } + if dev == "" { + return si.GPUs + } + n := 0 + for _, d := range strings.Split(dev, ",") { + if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(d)), "CUDA") { + n++ + } + } + return n +} + +// pipelineBlocker dit pourquoi llama.cpp n'ouvrira PAS le pipeline entre +// cartes (llama-context.cpp : toutes les couches sur GPU, découpe par couches, +// cache KV sur GPU, aucune surcharge de tenseurs). Vide = il peut l'ouvrir. +// --n-cpu-moe et --cpu-moe sont des surcharges de tenseurs déguisées : le +// preset MoE à experts sur CPU n'a donc jamais de pipeline. +func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]string) string { + if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") || envOn(argEnv["LLAMA_ARG_CPU_MOE"]) { + return "surcharge de tenseurs" + } + moe := flagValue(extra, "--n-cpu-moe", "-ncmoe") + if moe == "" { + moe = argEnv["LLAMA_ARG_N_CPU_MOE"] + } + if moe != "" && moe != "0" { + return "experts MoE sur CPU" + } + if hasAnyFlag(extra, "-nkvo", "--no-kv-offload") || envOn(argEnv["LLAMA_ARG_NO_KV_OFFLOAD"]) { + return "cache KV sur CPU" + } + sm := flagValue(extra, "-sm", "--split-mode") + if sm == "" { + sm = argEnv["LLAMA_ARG_SPLIT_MODE"] + } + if sm != "" && sm != "layer" { + return "découpe " + sm + } + // Couches GPU : celles d'EXTRA_ARGS, sinon NGL (que buildServeArgs traduit), + // sinon la variable du moteur. Un nombre choisi — hors la sentinelle 999 — + // laisse en général des couches au CPU ; auto, all et 999 visent le tout-GPU. + ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") + if ngl == "" { + ngl = strings.TrimSpace(cfg["NGL"]) + } + if ngl == "" || ngl == "auto" { + ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"] + } + if ngl != "" && ngl != "auto" && ngl != "all" && ngl != "999" { + return "couches GPU limitées à " + ngl + } + return "" +} + +// envOn lit une variable booléenne comme llama.cpp : vide, 0 ou false = non. +func envOn(v string) bool { + v = strings.ToLower(strings.TrimSpace(v)) + return v != "" && v != "0" && v != "false" +} + +// probeServeGPUs remplit ce dont launchQueuesEnv a besoin. On ne sonde +// nvidia-smi que si la réponse peut servir : variable déjà posée, « off », un +// --device explicite ou CUDA_VISIBLE_DEVICES suffisent à trancher sans lui. +func probeServeGPUs(cfg map[string]string, extra []string, si *serveSysInfo) { + si.LaunchQueues = os.Getenv("CUDA_SCALE_LAUNCH_QUEUES") + si.ArgEnv = map[string]string{} + for _, k := range serveArgEnv { + if v := os.Getenv(k); v != "" { + si.ArgEnv[k] = v + } + } + if si.LaunchQueues != "" || strings.EqualFold(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]), "off") { + return + } + if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" { + return + } + // cmdServe a déjà posé la sélection de config.env dans l'environnement : + // os.Getenv voit donc le preset d'abord, le conteneur ensuite. + if cvd := strings.TrimSpace(os.Getenv("CUDA_VISIBLE_DEVICES")); cvd != "" { + for _, d := range strings.Split(cvd, ",") { + if strings.TrimSpace(d) != "" { + si.GPUs++ + } + } + return + } + si.GPUs = nvidiaGPUCount(3 * time.Second) +} + +// nvidiaGPUCount compte les cartes NVIDIA. Borné dans le temps : un pilote +// coincé peut faire pendre nvidia-smi, et le démarrage du moteur ne doit pas +// en dépendre. Au moindre doute, 0 — on ne pose rien. +func nvidiaGPUCount(timeout time.Duration) int { + if !hasTool("nvidia-smi") { + return 0 + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index", "--format=csv,noheader")).Output() + if err != nil { + return 0 + } + n := 0 + for _, line := range strings.Split(string(out), "\n") { + if strings.TrimSpace(line) != "" { + n++ + } + } + return n +} diff --git a/internal/loki/backend_serve_queues_test.go b/internal/loki/backend_serve_queues_test.go new file mode 100644 index 0000000..0fa4bed --- /dev/null +++ b/internal/loki/backend_serve_queues_test.go @@ -0,0 +1,109 @@ +package loki + +import ( + "reflect" + "testing" +) + +// TestLaunchQueuesEnv : CUDA_SCALE_LAUNCH_QUEUES n'est posé que là où le +// pipeline entre cartes peut exister, et jamais par-dessus un choix de +// l'utilisateur. +func TestLaunchQueuesEnv(t *testing.T) { + two := serveSysInfo{GPUs: 2} + cases := []struct { + name string + cfg map[string]string + si serveSysInfo + want string + wantNotes int + }{ + {name: "2 GPU, dense tout sur GPU : 4x, et on le dit", + cfg: map[string]string{"NGL": "999"}, si: two, want: "4x", wantNotes: 1}, + {name: "NGL=all ou auto : pipeline possible", + cfg: map[string]string{"NGL": "all"}, si: two, want: "4x", wantNotes: 1}, + {name: "1 GPU : rien", + cfg: map[string]string{"NGL": "999"}, si: serveSysInfo{GPUs: 1}}, + {name: "nombre de GPU inconnu : rien", + cfg: map[string]string{}}, + {name: "variable déjà posée par l'utilisateur : intouchée", + cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2x"}, si: serveSysInfo{GPUs: 2, LaunchQueues: "1x"}}, + {name: "CUDA_LAUNCH_QUEUES=off : rien", + cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "off"}, si: two}, + {name: "valeur explicite valide : posée telle quelle, même sur 1 GPU", + cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2X"}, si: serveSysInfo{GPUs: 1}, want: "2x", wantNotes: 1}, + {name: "valeur invalide : ignorée, dite, puis automatique", + cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "8x"}, si: two, want: "4x", wantNotes: 2}, + {name: "valeur invalide sur 1 GPU : seulement la note", + cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "beaucoup"}, si: serveSysInfo{GPUs: 1}, wantNotes: 1}, + {name: "preset MoE (-ot, --n-cpu-moe) sur 2 GPU : rien", + cfg: map[string]string{"EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`}, si: two}, + {name: "--n-cpu-moe seul : rien", + cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe=12"}, si: two}, + {name: "--n-cpu-moe 0 : aucun expert sur CPU, pipeline possible", + cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"}, si: two, want: "4x", wantNotes: 1}, + {name: "--cpu-moe : rien", + cfg: map[string]string{"EXTRA_ARGS": "--cpu-moe"}, si: two}, + {name: "-sm row : rien", + cfg: map[string]string{"EXTRA_ARGS": "-sm row"}, si: two}, + {name: "--split-mode=layer explicite : 4x", + cfg: map[string]string{"EXTRA_ARGS": "--split-mode=layer"}, si: two, want: "4x", wantNotes: 1}, + {name: "NGL=28 : des couches restent au CPU, rien", + cfg: map[string]string{"NGL": "28"}, si: two}, + {name: "-ngl 40 dans EXTRA_ARGS l'emporte sur NGL=999 : rien", + cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "-ngl 40"}, si: two}, + {name: "cache KV sur CPU : rien", + cfg: map[string]string{"EXTRA_ARGS": "-nkvo"}, si: two}, + {name: "--device d'une seule carte : rien", + cfg: map[string]string{"EXTRA_ARGS": "--device CUDA0"}, si: two}, + {name: "--device de deux cartes CUDA, nvidia-smi non sondé : 4x", + cfg: map[string]string{"EXTRA_ARGS": "--device CUDA1,CUDA0 --tensor-split 0.6,0.4"}, want: "4x", wantNotes: 1}, + {name: "--device Vulkan : la variable ne concerne pas ce moteur", + cfg: map[string]string{"EXTRA_ARGS": "-dev Vulkan0,Vulkan1"}, si: two}, + {name: "--device none : rien", + cfg: map[string]string{"EXTRA_ARGS": "-dev none"}, si: two}, + {name: "LLAMA_ARG_N_CPU_MOE dans l'environnement du moteur : rien", + cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}}, + {name: "LLAMA_ARG_SPLIT_MODE=none : rien", + cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "none"}}}, + {name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=20 : rien", + cfg: map[string]string{"NGL": "auto"}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}}, + {name: "LLAMA_ARG_CPU_MOE=0 : désactivé, pipeline possible", + cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "0"}}, want: "4x", wantNotes: 1}, + {name: "LLAMA_ARG_DEVICE d'une seule carte : rien", + cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_DEVICE": "CUDA1"}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, notes := launchQueuesEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si) + if got != c.want { + t.Fatalf("valeur = %q, attendu %q", got, c.want) + } + if len(notes) != c.wantNotes { + t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes) + } + }) + } +} + +// La variable passe par l'environnement : la ligne de commande ne bouge pas +// d'un octet, et la sélection GPU l'accompagne. +func TestBuildServeArgsLaunchQueues(t *testing.T) { + const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf" + cfg := map[string]string{"NGL": "999", "CUDA_VISIBLE_DEVICES": "1,0"} + si := serveSysInfo{Help: helpRecent, Model: model} + without, _, _ := buildServeArgs(cfg, nil, bin, si) + si.GPUs = 2 + args, env, notes := buildServeArgs(cfg, nil, bin, si) + if !reflect.DeepEqual(args, without) { + t.Fatalf("la ligne de commande a changé\n got %q\nwant %q", args, without) + } + want := map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_SCALE_LAUNCH_QUEUES": "4x"} + if !reflect.DeepEqual(env, want) { + t.Fatalf("env = %v, want %v", env, want) + } + // Une note pour 999 → auto, une pour les files de lancement. + if len(notes) != 2 { + t.Fatalf("notes = %q", notes) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 92c851e..a25112e 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3437,7 +3437,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
- Experts MoE sur CPUvide = non + Experts MoE sur CPUvide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)
diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index ef252e0..798ee12 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -1214,7 +1214,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
- Experts MoE sur CPUvide = non + Experts MoE sur CPUvide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)