mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset demandait de dupliquer, basculer et comparer des bench complets un par un. L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni à ce que calcule le modèle, et n'écrit rien sans un clic. - Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur 127.0.0.1 et un port libre, groupe de processus tué en sortie - Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage + binaire) vérifié par serviceAction start/restart et par le chat, les tâches, bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur - Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande - EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de chaque essai composée à blanc et comparée à la référence (« dénature »), contexte, slots et cache KV recontrôlés une fois chargé - Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages) - Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit clé par clé, vérifié par une sonde et rétabli en cas d'échec Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
8.7 KiB
Go
273 lines
8.7 KiB
Go
package loki
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// backend_gpu.go — sélection du/des GPU utilisés par llama-server.
|
||
//
|
||
// loki gpu liste les GPU et montre la sélection courante
|
||
// loki gpu 1 n'utilise que le GPU d'index 1
|
||
// loki gpu 0 1 utilise les GPU 0 et 1
|
||
// loki gpu all réinitialise (tous les GPU visibles)
|
||
//
|
||
// La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; backend_serve.go
|
||
// l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent
|
||
// à ceux affichés par nvidia-smi).
|
||
|
||
type gpuInfo struct {
|
||
Index int
|
||
Name string
|
||
MemTotal string // en MiB
|
||
MemUsed string
|
||
Cap string // compute capability
|
||
|
||
// Liaison PCIe et horloge mémoire, pour guider le placement entre cartes
|
||
// inégales (voir annotateDevices). Zéro = inconnu : pilote trop ancien,
|
||
// « [N/A] », ou requête retombée sur les seuls champs historiques.
|
||
BusID string
|
||
LinkGen int // génération PCIe ACTUELLE — elle descend au repos (économie d'énergie)
|
||
LinkGenMax int // génération maximale négociable carte + carte mère
|
||
LinkWidth int // largeur actuelle (×N), elle aussi réduite au repos sur certaines cartes
|
||
LinkWidthMax int
|
||
MemClockMax int // horloge mémoire maximale, MHz
|
||
}
|
||
|
||
// gpuBaseFields : la requête historique de detectGPUs, que tout pilote connaît.
|
||
// gpuLinkFields : les champs de liaison, ajoutés en fin de ligne. nvidia-smi
|
||
// refuse TOUTE la requête au moindre champ inconnu (« is not a valid field to
|
||
// query ») : la largeur du bus mémoire, qui n'existe pas, n'y figure donc pas,
|
||
// et un refus fait retomber sur la requête historique — jamais de GPU perdu
|
||
// pour une information de confort.
|
||
const (
|
||
gpuBaseFields = "index,name,memory.total,memory.used,compute_cap"
|
||
gpuLinkFields = "pci.bus_id,pcie.link.gen.current,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,clocks.max.memory"
|
||
)
|
||
|
||
// nvidiaSmiGPUQuery lance « nvidia-smi --query-gpu=<fields> ». Variable pour
|
||
// que les tests substituent un faux nvidia-smi. Borné dans le temps comme la
|
||
// lecture des jauges (nvidiaSmiTimeout) : un pilote coincé ne doit pas figer
|
||
// « loki gpu » ni l'éditeur de preset.
|
||
var nvidiaSmiGPUQuery = func(fields string) ([]byte, error) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), nvidiaSmiTimeout)
|
||
defer cancel()
|
||
cmd := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu="+fields, "--format=csv,noheader,nounits"))
|
||
cmd.WaitDelay = time.Second
|
||
return cmd.Output()
|
||
}
|
||
|
||
func cmdGPU(args []string) error {
|
||
if len(args) == 0 || args[0] == "list" || args[0] == "ls" {
|
||
return gpuList()
|
||
}
|
||
switch args[0] {
|
||
case "all", "reset", "none", "auto":
|
||
return gpuSet("")
|
||
}
|
||
// Sinon : une liste d'index (« 1 », « 0 1 », « 0,1 »).
|
||
gpus, err := detectGPUs()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
raw := strings.Join(args, ",")
|
||
var idx []string
|
||
for _, tok := range strings.Split(raw, ",") {
|
||
tok = strings.TrimSpace(tok)
|
||
if tok == "" {
|
||
continue
|
||
}
|
||
n, err := strconv.Atoi(tok)
|
||
if err != nil {
|
||
return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok)
|
||
}
|
||
if n < 0 || n >= len(gpus) {
|
||
return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « loki gpu »", n, len(gpus)-1)
|
||
}
|
||
idx = append(idx, strconv.Itoa(n))
|
||
}
|
||
if len(idx) == 0 {
|
||
return fmt.Errorf("aucun index fourni")
|
||
}
|
||
return gpuSet(strings.Join(idx, ","))
|
||
}
|
||
|
||
// gpuList affiche les GPU détectés et marque ceux actuellement sélectionnés.
|
||
func gpuList() error {
|
||
gpus, err := detectGPUs()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
sel := ReadConfig()["CUDA_VISIBLE_DEVICES"]
|
||
selected := map[int]bool{}
|
||
if sel != "" {
|
||
for _, t := range strings.Split(sel, ",") {
|
||
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
|
||
selected[n] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
fmt.Println()
|
||
for _, g := range gpus {
|
||
mark := " "
|
||
line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap)
|
||
if l := g.linkSummary(); l != "" {
|
||
line += " " + l
|
||
}
|
||
active := sel == "" || selected[g.Index]
|
||
if sel != "" && selected[g.Index] {
|
||
mark = green("● ")
|
||
line = green(line)
|
||
} else if sel == "" {
|
||
mark = dim("○ ")
|
||
} else {
|
||
mark = dim("○ ")
|
||
line = dim(line)
|
||
}
|
||
_ = active
|
||
fmt.Printf(" %s%s\n", mark, line)
|
||
}
|
||
fmt.Println()
|
||
if sel == "" {
|
||
fmt.Printf(" Sélection : %s (tous les GPU)\n", bold("auto"))
|
||
} else {
|
||
fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel)
|
||
}
|
||
fmt.Printf(" %s loki gpu <index…> pour choisir, loki gpu all pour réinitialiser\n", dim("→"))
|
||
return nil
|
||
}
|
||
|
||
// gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a
|
||
// restart so the change takes effect.
|
||
func gpuSet(value string) error {
|
||
// La sélection GPU relance le moteur : pas pendant une optimisation.
|
||
if err := tuneGuard(); err != nil {
|
||
return err
|
||
}
|
||
if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil {
|
||
return err
|
||
}
|
||
if value == "" {
|
||
fmt.Printf("%s sélection GPU réinitialisée — tous les GPU seront visibles\n", green("[ok]"))
|
||
} else {
|
||
fmt.Printf("%s GPU sélectionné(s) : %s\n", green("[ok]"), bold(value))
|
||
}
|
||
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
|
||
sc := bufio.NewScanner(os.Stdin)
|
||
if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
|
||
fmt.Println(dim("[info] pense à lancer 'loki restart'"))
|
||
return nil
|
||
}
|
||
return serviceAction("restart")
|
||
}
|
||
|
||
// detectGPUs queries nvidia-smi for the list of NVIDIA GPUs.
|
||
func detectGPUs() ([]gpuInfo, error) {
|
||
if !hasTool("nvidia-smi") {
|
||
return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA")
|
||
}
|
||
return queryGPUs(nvidiaSmiGPUQuery)
|
||
}
|
||
|
||
// queryGPUs : le cœur de detectGPUs, la requête passée en paramètre pour être
|
||
// testée sans nvidia-smi. UNE invocation d'ordinaire : champs historiques et
|
||
// liaison ensemble. La seconde n'a lieu que si nvidia-smi refuse un champ de
|
||
// liaison (pilote ancien) — pas sur un délai dépassé, qu'une relance ne ferait
|
||
// que doubler.
|
||
func queryGPUs(query func(fields string) ([]byte, error)) ([]gpuInfo, error) {
|
||
out, err := query(gpuBaseFields + "," + gpuLinkFields)
|
||
if err != nil && smiFieldRefused(out, err) {
|
||
out, err = query(gpuBaseFields)
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("nvidia-smi a échoué: %w", err)
|
||
}
|
||
gpus := parseGPUQuery(string(out))
|
||
if len(gpus) == 0 {
|
||
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
|
||
}
|
||
return gpus, nil
|
||
}
|
||
|
||
// smiFieldRefused : nvidia-smi a-t-il rejeté un champ de la requête ? Il
|
||
// l'écrit sur sa sortie standard et sort en code 2 (argument invalide).
|
||
func smiFieldRefused(out []byte, err error) bool {
|
||
if strings.Contains(string(out), "valid field") {
|
||
return true
|
||
}
|
||
var ee *exec.ExitError
|
||
return errors.As(err, &ee) && ee.ExitCode() == 2
|
||
}
|
||
|
||
// parseGPUQuery lit la sortie CSV de detectGPUs, avec ou sans les champs de
|
||
// liaison. Fonction pure. Une ligne de moins de cinq colonnes est ignorée,
|
||
// comme avant ; une valeur de liaison illisible (« [N/A] », « [Not
|
||
// Supported] ») vaut zéro, sans faire tomber la carte.
|
||
func parseGPUQuery(out string) []gpuInfo {
|
||
var gpus []gpuInfo
|
||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||
parts := strings.Split(line, ",")
|
||
if len(parts) < 5 {
|
||
continue
|
||
}
|
||
for i := range parts {
|
||
parts[i] = strings.TrimSpace(parts[i])
|
||
}
|
||
idx, _ := strconv.Atoi(parts[0])
|
||
g := gpuInfo{Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4]}
|
||
if len(parts) >= 11 {
|
||
if b := parts[5]; b != "" && b != "N/A" && !strings.HasPrefix(b, "[") {
|
||
g.BusID = b
|
||
}
|
||
g.LinkGen, g.LinkGenMax = smiNum(parts[6]), smiNum(parts[7])
|
||
g.LinkWidth, g.LinkWidthMax = smiNum(parts[8]), smiNum(parts[9])
|
||
g.MemClockMax = smiNum(parts[10])
|
||
}
|
||
gpus = append(gpus, g)
|
||
}
|
||
return gpus
|
||
}
|
||
|
||
// smiNum lit un entier de nvidia-smi ; « [N/A] », vide ou autre chose = 0.
|
||
func smiNum(s string) int {
|
||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||
if err != nil || n < 0 {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
|
||
// linkSummary : la liaison PCIe en clair, « PCIe 4 ×16 », maximum d'abord —
|
||
// l'actuel descend au repos et ne dit rien de la carte sous charge. Vide =
|
||
// inconnue.
|
||
func (g gpuInfo) linkSummary() string {
|
||
if g.LinkGenMax == 0 && g.LinkWidthMax == 0 {
|
||
return ""
|
||
}
|
||
s := "PCIe"
|
||
if g.LinkGenMax > 0 {
|
||
s += " " + strconv.Itoa(g.LinkGenMax)
|
||
}
|
||
if g.LinkWidthMax > 0 {
|
||
s += " ×" + strconv.Itoa(g.LinkWidthMax)
|
||
}
|
||
if (g.LinkGen > 0 && g.LinkGen < g.LinkGenMax) || (g.LinkWidth > 0 && g.LinkWidth < g.LinkWidthMax) {
|
||
known := func(n int) string {
|
||
if n <= 0 {
|
||
return "?"
|
||
}
|
||
return strconv.Itoa(n)
|
||
}
|
||
s += " (actuellement " + known(g.LinkGen) + " ×" + known(g.LinkWidth) + ", réduit au repos)"
|
||
}
|
||
return s
|
||
}
|