From cf83f5f6296fa559453e3ffdc4ad5d691f794492 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 02:52:54 +0200 Subject: [PATCH] =?UTF-8?q?Bench=20:=20une=20mesure=20honn=C3=AAte=20?= =?UTF-8?q?=C3=A0=20profondeur=20r=C3=A9elle,=20en=20t=C3=A2che=20de=20fon?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'ancien bench ne lisait pas le statut HTTP, inventait un partage 15/85 du temps quand les timings manquaient (et l'enregistrait comme mesuré), tuilait un petit corpus qu'un brouillon recopiait, et ne disait rien du prefill à 30k ni du chemin où le cache est repris. Synchrone, il était coupé par les proxys (60-100 s) pendant que le moteur continuait, et un simple GET suffisait à le lancer. - Tâche de fond : POST /api/bench (202, 405 sur GET, 409 si occupé), /api/bench/status pour la progression, /api/bench/cancel ; chaque requête au moteur porte le contexte annulable. - Verrou de génération tenu pendant toute la mesure : chat, tâches et compaction reçoivent un refus clair ; les clients /v1 un 503 avec Retry-After ; /slots occupé (client externe, CLI) refuse aussi. - Mode rapide par défaut : préchauffage jeté + la ligne courte, sur le chat avec gabarit, raisonnement et échantillonnage du preset, seed fixe. - Mode complet (bouton « bench complet », loki bench --full) : prefill à froid à D = min(CTX/2, 32k, ce qui laisse tenir les tours), 16k si des poids tournent sur CPU, puis 3 tours user → assistant → user de code jamais vu. Reprise du cache lue dans cache_n, jamais supposée ; note pour les hybrides (points de contrôle). Pas d'ignore_eos. - Rien n'est enregistré sans réponses 200 et timings réels, ni pour un résultat partiel (budget de 6 min par requête, contexte plein). - Empreinte enregistrée (configuration + CUDA_VISIBLE_DEVICES + protocole) ; les anciennes mesures retombent sur le nom du modèle. Types de cache KV et build du moteur affichés, KV quantifié signalé (zone grise). - Linux : indication « possible thrash » (read_bytes, VmRSS), jamais enregistrée. - Effacement du slot en fin de bench inchangé (engineSideJob, différé : il tourne aussi sur erreur et annulation). Co-Authored-By: Claude Opus 5.5 --- internal/loki/backend_external_test.go | 2 +- internal/loki/chat_conversation.go | 16 +- internal/loki/llm_bench.go | 992 +++++++++++++++++++++---- internal/loki/llm_bench_job.go | 235 ++++++ internal/loki/llm_bench_match_test.go | 6 +- internal/loki/llm_bench_test.go | 543 ++++++++++++++ internal/loki/llm_oai.go | 8 + internal/loki/perf_log.go | 1 + internal/loki/run.go | 2 +- internal/loki/sys_loadpct_linux.go | 24 + internal/loki/sys_loadpct_other.go | 3 + internal/loki/ui/index.html | 132 +++- internal/loki/ui/src/index.tmpl.html | 2 + internal/loki/ui/src/js/06-settings.js | 7 +- internal/loki/ui/src/js/07-models.js | 123 ++- internal/loki/web_api.go | 49 +- internal/loki/web_server.go | 4 +- 17 files changed, 1904 insertions(+), 245 deletions(-) create mode 100644 internal/loki/llm_bench_job.go create mode 100644 internal/loki/llm_bench_test.go diff --git a/internal/loki/backend_external_test.go b/internal/loki/backend_external_test.go index 8db84fc..07283a7 100644 --- a/internal/loki/backend_external_test.go +++ b/internal/loki/backend_external_test.go @@ -230,7 +230,7 @@ func TestPresetExterneVisionEtBench(t *testing.T) { if !visionEnabled() || !engineSeesImages() { t.Error("preset externe déclaré multimodal : la vision devrait être active") } - if _, err := runBench(10, 10); err == nil { + if _, err := runBench(context.Background(), benchOpts{Prompt: 10, Predict: 10}, nil); err == nil { t.Error("benchmark lancé sur un preset externe") } if got := chatModelName(); got != "gpt-4o" { diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 2acb7a4..7991eb2 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -77,6 +77,9 @@ type Conversation struct { // à une génération fantôme dans la discussion ouverte. runningTaskID string runningTaskName string + // benching : le verrou est tenu par un benchmark (llm_bench_job.go). Même + // logique que la tâche : l'interface et les refus disent qui occupe le moteur. + benching bool // File d'attente des messages envoyés PENDANT une génération (AJEAN // 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière @@ -443,8 +446,11 @@ var ErrBusy = fmt.Errorf("génération en cours") // nomme la tâche et on dit comment reprendre la main. func (c *Conversation) busyReason() error { c.mu.Lock() - name := c.runningTaskName + name, bench := c.runningTaskName, c.benching c.mu.Unlock() + if bench { + return errBenchBusy + } if name == "" { return ErrBusy } @@ -550,8 +556,8 @@ func (c *Conversation) seenCIDLocked(cid string) bool { // EnqueueOrStart démarre un tour tout de suite si le moteur est libre, sinon // MET EN FILE le message (AJEAN 0.14.0) — au lieu du 409 d'avant, qui obligeait // à arrêter la réponse pour ajouter une précision. Seul un tour de CHAT accepte -// une file : une tâche planifiée qui occupe le modèle garde le refus -// (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir +// une file : une tâche planifiée ou un benchmark qui occupe le modèle garde le +// refus (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir // recentCIDs) ; un doublon renvoie ErrDupSend. func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps Caps, temperature float64) (bool, error) { c.mu.Lock() @@ -559,7 +565,7 @@ func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps c.mu.Unlock() return false, ErrDupSend } - if c.Generating && c.runningTaskName == "" { + if c.Generating && c.runningTaskName == "" && !c.benching { c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature}) c.mu.Unlock() return true, nil @@ -571,7 +577,7 @@ func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps // appareils qui envoient en même temps) : en file plutôt qu'un refus // (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien. c.mu.Lock() - if c.Generating && c.runningTaskName == "" { + if c.Generating && c.runningTaskName == "" && !c.benching { c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature}) c.mu.Unlock() // Ce tour a pu finir entre-temps : sans relance, le message attendrait diff --git a/internal/loki/llm_bench.go b/internal/loki/llm_bench.go index 5b69ef4..31786f4 100644 --- a/internal/loki/llm_bench.go +++ b/internal/loki/llm_bench.go @@ -2,16 +2,137 @@ package loki import ( "bytes" + "context" "encoding/json" + "errors" "fmt" + "io" "net/http" + "os" "path/filepath" "strconv" "strings" "time" + "unicode/utf8" ) -// benchResult captures the timings llama.cpp returns from /completion. +// Le benchmark mesure le moteur LOCAL tel que le chat s'en sert. Deux modes : +// +// - « rapide » (le bouton par défaut) : un préchauffage jeté, puis la ligne +// courte historique — ~2000 jetons de prompt, ~300 générés — sur +// /v1/chat/completions, avec le gabarit, le raisonnement et l'échantillonnage +// du preset. Une seule mesure à froid, à faible profondeur ; +// - « complet » : la ligne courte, puis une discussion à PROFONDEUR réelle — +// un prefill à froid de D jetons, et trois tours qui ajoutent chacun ~2048 +// jetons neufs à la même discussion. On y lit le prefill à froid à D, le +// prefill des tours suivants (cache repris) et le decode à D : ce que la +// ligne courte ne voit pas, et ce qui décide vraiment du confort à 30k. +// +// Tout passe par l'endpoint du chat, avec une vraie structure de messages +// (utilisateur → assistant, contenu seul, comme le gabarit le reconstruit → +// utilisateur) : la reprise du cache est LUE dans la réponse (cache_n), jamais +// supposée. Un modèle hybride ne reprend qu'aux points de contrôle, un gabarit +// qui retire le raisonnement de l'historique réécrit la fin du préfixe : la +// mesure le montre au lieu de promettre le meilleur cas. +// +// Honnêteté de la mesure : une réponse non-200 ou sans timings fait échouer la +// phase — rien n'est inventé, rien n'est enregistré. Un compteur absent +// (cache_n, draft_n : selon le build) reste « n/a ». Rien ici ne touche au +// modèle : seed fixe et cache coupé ne valent que pour ces requêtes-là. + +// benchProtocol : version du protocole, enregistrée avec chaque mesure. Deux +// protocoles ne se comparent pas (la v1 tuilait un petit corpus et inventait un +// partage 15/85 du temps quand les timings manquaient). +const benchProtocol = 2 + +const ( + benchModeQuick = "quick" + benchModeFull = "full" + + benchTurns = 3 // tours ajoutés à la discussion en profondeur + benchAppendTokens = 2048 // jetons NEUFS par tour + benchGenTokens = 256 // jetons générés par tour (arrêt sur EOS permis) + benchDepthMax = 32768 // profondeur plafond + benchDepthCPU = 16384 // plafond quand des poids tournent sur CPU (MoE déporté) + benchDepthMin = 4096 // en dessous, la profondeur n'apprend rien : phase sautée + benchMargin = 512 // gabarit, questions, consignes : la marge du contexte + benchWarmGen = 32 // jetons générés au préchauffage + benchSeed = 1234 + // benchReuseSlack : jetons de fin de préfixe que le gabarit peut légitimement + // réécrire d'un tour à l'autre (invite de génération, balise de réflexion). + benchReuseSlack = 16 + // benchSampleBytes : échantillon tokenisé pour estimer les octets par jeton. + benchSampleBytes = 160 << 10 + // benchFallbackRatio : octets par jeton sans /tokenize. Plus bas que la + // réalité du code comme de la prose : des morceaux plus COURTS que visé, + // la mesure tient toujours dans le contexte. + benchFallbackRatio = 2.5 + // benchThrashBytes : relu du disque par 256 jetons générés au-delà duquel + // on évoque un modèle qui ne tient pas en RAM. Seuil non calibré : simple + // indication, jamais enregistrée. + benchThrashBytes = 512 << 20 +) + +// benchReqTimeout : budget d'une requête. Un prefill à froid de 16k jetons +// avec les experts sur CPU prend une à deux minutes ; au-delà de ce budget la +// phase est déclarée partielle, le reste du bench garde ses mesures. +var benchReqTimeout = 6 * time.Minute + +var errBenchNoTimings = errors.New("le moteur n'a pas renvoyé de mesures (timings) : rien à enregistrer") + +// benchDraft : acceptation du brouillon (MTP, n-gram, modèle d'ébauche), lue +// dans timings.draft_n / draft_n_accepted. nil = le moteur ne le dit pas. +type benchDraft struct { + N int `json:"n"` + Accepted int `json:"accepted"` +} + +func (d *benchDraft) add(o *benchDraft) *benchDraft { + if o == nil { + return d + } + if d == nil { + d = &benchDraft{} + } + d.N += o.N + d.Accepted += o.Accepted + return d +} + +// benchTurn : une requête de la phase en profondeur. +type benchTurn struct { + New int `json:"new"` // jetons du prompt réellement calculés (prompt_n) + Cached int `json:"cached"` // repris du cache ; -1 = le moteur ne le dit pas + Expected int `json:"expected"` // prompt du tour précédent : ce qui POUVAIT être repris + PromptMs float64 `json:"prompt_ms"` + PromptPerSecond float64 `json:"prompt_per_second"` + PredictedN int `json:"predicted_n"` + PredictedMs float64 `json:"predicted_ms"` + PredictedPerSec float64 `json:"predicted_per_second"` + Draft *benchDraft `json:"draft,omitempty"` +} + +// benchDepth : la phase en profondeur du mode complet. +type benchDepth struct { + Ctx int `json:"ctx"` + Target int `json:"target"` // profondeur visée (jetons) + Skipped string `json:"skipped,omitempty"` // phase sautée, et pourquoi + Cold *benchTurn `json:"cold,omitempty"` + Turns []benchTurn `json:"turns,omitempty"` + // Agrégats des tours : prefill des jetons neufs (cache repris) et decode à D. + CachedPerSec float64 `json:"cached_per_second,omitempty"` + DecodePerSec float64 `json:"decode_per_second,omitempty"` + // Reuse : part du prompt précédent reprise du cache ; -1 = inconnue. + Reuse float64 `json:"reuse"` + ReuseNote string `json:"reuse_note,omitempty"` + Draft *benchDraft `json:"draft,omitempty"` // sortie en code + Partial string `json:"partial,omitempty"` + // Hint : indication « possible thrash » (Linux), jamais enregistrée. + Hint string `json:"hint,omitempty"` +} + +// benchResult : les premiers champs sont ceux de la ligne courte, inchangés +// depuis la v1 — l'interface et les pastilles des presets les lisent. type benchResult struct { PromptN int `json:"prompt_n"` PromptMs float64 `json:"prompt_ms"` @@ -20,12 +141,49 @@ type benchResult struct { PredictedMs float64 `json:"predicted_ms"` PredictedPerSec float64 `json:"predicted_per_second"` Elapsed float64 `json:"elapsed_sec"` + + Mode string `json:"mode,omitempty"` + Protocol int `json:"protocol,omitempty"` + Draft *benchDraft `json:"draft,omitempty"` // ligne courte, sortie en prose + // KV : types de cache effectifs. Quantifié = zone grise : la mesure ne se + // compare pas à une référence sans perte. + KV string `json:"kv,omitempty"` + KVQuantized bool `json:"kv_quantized,omitempty"` + Engine string `json:"engine,omitempty"` // build_info du moteur + GPUs string `json:"gpus,omitempty"` // CUDA_VISIBLE_DEVICES effectif + Depth *benchDepth `json:"depth,omitempty"` } -// benchCorpus is a varied passage used to defeat speculative decoding -// (MTP / n-gram draft) — repetitive text inflates decode tok/s because every -// drafted token gets accepted, which is unlike real chat. We pull a chunk of -// natural-looking content and tile it to reach the target prompt size. +// benchOpts : ce que demande l'appelant. +type benchOpts struct { + Mode string + Prompt int // jetons de la ligne courte + Predict int // jetons générés par la ligne courte +} + +func (o benchOpts) full() bool { return o.Mode == benchModeFull } + +// benchProgress annonce la phase en cours (step sur steps). nil : silence. +type benchProgress func(phase string, step, steps int) + +// benchCorpora : prose pour la ligne courte, code pour la profondeur. Le code +// est l'interface web embarquée — 650 Ko de HTML/CSS/JS réels, sans répétition : +// une discussion profonde ne relit jamais deux fois le même passage, un +// brouillon n-gram n'y trouve pas de copie toute faite. +type benchCorpora struct { + prose, code string +} + +func defaultBenchCorpora() benchCorpora { + b, _ := uiFS.ReadFile("ui/index.html") + return benchCorpora{prose: benchCorpus, code: string(b)} +} + +// benchCorpus : un passage varié (prose française et anglaise, sujets sans +// lien) pour la ligne courte. Il n'est plus tuilé : un texte répété gonfle le +// decode, chaque jeton proposé par un brouillon (MTP, n-gram) étant accepté — +// rien à voir avec un vrai chat. Au-delà de sa taille, le prompt se complète +// avec du code inédit (benchRun). const benchCorpus = `In the early hours of an October morning, Camille walked along the canal, watching the cargo barges slip past the iron bridge that spanned the water. She thought about the meeting she had skipped, the unanswered messages on her phone, the way the city always seemed to forget her name after summer ended. Three streets away, a pâtisserie opened its shutters and the smell of warm butter mixed with diesel exhaust from the waiting bus. Pendant ce temps, à Marseille, un chercheur en biologie marine prépare son matériel pour une plongée. Il étudie les herbiers de posidonie, ces prairies sous-marines vieilles de plusieurs milliers d'années qui stockent autant de carbone qu'une forêt amazonienne. Le bateau quitte le port à six heures vingt-trois. Quantum computers, properly engineered, can solve certain classes of problems exponentially faster than classical machines. The catch is that decoherence ruins everything. Engineers use dilution refrigerators to drop superconducting qubits to fifteen millikelvin, colder than deep space. The wires connecting the chip to room-temperature electronics must dissipate almost no heat, or the qubit state collapses before any useful computation finishes. @@ -38,12 +196,562 @@ Compilers translate high-level languages into machine code through several inter Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une pomme de terre crue coupée en quartiers, sachant qu'elle absorbera l'excès en mijotant vingt minutes. Sa grand-mère lui a appris ce geste un dimanche de novembre il y a très longtemps. ` -// runBench fires a prompt of roughly `nPrompt` tokens at /completion with -// cache_prompt:false (so prefill is actually measured, not cached). The prompt -// is a varied corpus to keep speculative decoding (MTP / n-gram draft) from -// inflating decode numbers — what you measure here is close to what you'll -// see in real chat at the same context length. -func runBench(nPrompt, nPredict int) (*benchResult, error) { +// --- le moteur, vu du bench --------------------------------------------------- + +// benchEngine parle au moteur local ; séparé pour les tests (faux moteur). +type benchEngine struct { + base string // http://localhost: + auth func(set func(k, v string)) + client *http.Client +} + +// do envoie une requête et rend le corps d'une réponse 200. Toute autre réponse +// est une erreur : la v1 décodait un 500 comme une mesure. +func (e benchEngine) do(ctx context.Context, method, path string, payload any) ([]byte, error) { + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, e.base+path, body) + if err != nil { + return nil, err + } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if e.auth != nil { + e.auth(req.Header.Set) + } + // Requête en vol jusqu'à la lecture complète du corps : l'isolation des + // travaux annexes n'efface jamais le slot pendant ce temps (llm_slots.go). + defer engineRequestStart()() + resp, err := e.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if resp.StatusCode != http.StatusOK { + snip := strings.TrimSpace(string(data)) + if len(snip) > 300 { + snip = snip[:300] + } + return nil, fmt.Errorf("%s %s : %s %s", method, path, resp.Status, snip) + } + return data, err +} + +// idle : le moteur est-il libre ? Un slot qui travaille, c'est une requête +// d'un autre (client /v1, autre processus loki) : le bench passerait derrière +// elle et mesurerait l'attente. /slots coupé (501) ou absent : on ne sait pas, +// on n'empêche rien. +func (e benchEngine) idle(ctx context.Context) error { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + data, err := e.do(ctx, http.MethodGet, "/slots", nil) + if err != nil { + return nil + } + var slots []struct { + IsProcessing bool `json:"is_processing"` + } + if json.Unmarshal(data, &slots) != nil { + return nil + } + for _, s := range slots { + if s.IsProcessing { + return errors.New("le moteur traite déjà une requête : relance le bench une fois libre") + } + } + return nil +} + +// props lit la taille de contexte et le build du moteur. Absents : 0 et "". +func (e benchEngine) props(ctx context.Context) (nCtx int, build string) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + data, err := e.do(ctx, http.MethodGet, "/props", nil) + if err != nil { + return 0, "" + } + var p struct { + BuildInfo string `json:"build_info"` + NCtx int `json:"n_ctx"` + Default struct { + NCtx int `json:"n_ctx"` + } `json:"default_generation_settings"` + } + if json.Unmarshal(data, &p) != nil { + return 0, "" + } + nCtx = p.Default.NCtx + if nCtx <= 0 { + nCtx = p.NCtx + } + return nCtx, p.BuildInfo +} + +// bytesPerToken : octets par jeton d'un échantillon, mesurés par /tokenize. +func (e benchEngine) bytesPerToken(ctx context.Context, sample string) float64 { + if sample == "" { + return benchFallbackRatio + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + data, err := e.do(ctx, http.MethodPost, "/tokenize", map[string]any{"content": sample}) + if err != nil { + return benchFallbackRatio + } + var out struct { + Tokens []json.RawMessage `json:"tokens"` + } + if json.Unmarshal(data, &out) != nil || len(out.Tokens) == 0 { + return benchFallbackRatio + } + return float64(len(sample)) / float64(len(out.Tokens)) +} + +// benchReply : une réponse du chat. total : usage.prompt_tokens (0 = inconnu). +type benchReply struct { + turn benchTurn + total int + content string +} + +// chat envoie une complétion et en lit les timings. Sans timings, pas de +// mesure : la v1 inventait un partage 15/85 du temps écoulé, enregistré ensuite +// comme s'il avait été mesuré. +func (e benchEngine) chat(ctx context.Context, payload map[string]any) (benchReply, error) { + ctx, cancel := context.WithTimeout(ctx, benchReqTimeout) + defer cancel() + data, err := e.do(ctx, http.MethodPost, "/v1/chat/completions", payload) + if err != nil { + return benchReply{}, err + } + engineServed() // le slot porte désormais le bench : l'effacer a un sens + w := decodePerfWire(data) + t := w.Timings + if t == nil || !t.PromptN.ok || !t.PromptMs.ok || !t.PredictedN.ok || !t.PredictedMs.ok { + return benchReply{}, errBenchNoTimings + } + rate := func(n, ms, given perfNum) float64 { + if given.ok && given.f > 0 { + return given.f + } + if ms.f > 0 { + return n.f / (ms.f / 1000) + } + return 0 + } + r := benchReply{turn: benchTurn{ + New: t.PromptN.int(), Cached: -1, + PromptMs: t.PromptMs.f, PromptPerSecond: rate(t.PromptN, t.PromptMs, t.PromptPerSecond), + PredictedN: t.PredictedN.int(), PredictedMs: t.PredictedMs.f, + PredictedPerSec: rate(t.PredictedN, t.PredictedMs, t.PredictedPerSec), + }} + if p := t.CacheN.ptr(); p != nil { + r.turn.Cached = *p + } else if p := w.cachedTokens().ptr(); p != nil { + r.turn.Cached = *p + } + if n, a := t.DraftN.ptr(), t.DraftNAccepted.ptr(); n != nil && a != nil && *n > 0 { + r.turn.Draft = &benchDraft{N: *n, Accepted: *a} + } + if w.Usage != nil && w.Usage.PromptTokens.ok { + r.total = w.Usage.PromptTokens.int() + } + var msg struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if json.Unmarshal(data, &msg) == nil && len(msg.Choices) > 0 { + r.content = msg.Choices[0].Message.Content + } + return r, nil +} + +// promptTotal : taille du prompt entier de ce tour. +func (r benchReply) promptTotal() int { + if r.total > 0 { + return r.total + } + return r.turn.New + max(r.turn.Cached, 0) +} + +// --- réglages du preset -------------------------------------------------------- + +// benchArgEnv : les LLAMA_ARG_* qui décident, sans drapeau, des types de cache +// et du placement des poids. +var benchArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V", + "LLAMA_ARG_OVERRIDE_TENSOR", "LLAMA_ARG_CPU_MOE", "LLAMA_ARG_N_CPU_MOE", "LLAMA_ARG_N_CPU_FFN"} + +// benchSetup : ce que le bench tire du preset, une fois. +type benchSetup struct { + cfg map[string]string + effort string + kwargs map[string]any + ub int + cpuPlaced bool // poids sur CPU (-ot, --n-cpu-moe…) : profondeur plafonnée + hybrid bool // modèle hybride : reprise du cache aux points de contrôle + kv string + kvQuant bool + gpus string +} + +func benchSetupFrom(cfg map[string]string, argEnv map[string]string) benchSetup { + extra := splitArgs(cfg["EXTRA_ARGS"]) + // Le raisonnement tel que le chat l'envoie (runChatTools) : mesurer sans lui + // serait mesurer un autre usage que le sien. + want := reasoningEffortValue(cfg["REASONING_EFFORT"]) + effort := effortResolve(want) + off := reasoningExplicitlyOff(cfg["REASONING"]) || want == "none" + s := benchSetup{cfg: cfg, effort: effort, kwargs: reasoningTemplateKwargs(off, effort), ub: 512, + gpus: strings.TrimSpace(cfg["CUDA_VISIBLE_DEVICES"])} + for _, v := range []string{flagValue(extra, "-ub", "--ubatch-size"), cfg["UBATCH"]} { + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 { + s.ub = n + break + } + } + s.cpuPlaced = tensorOverride(extra, argEnv) != "" + k, v, _ := effectiveKVTypes(cfg, extra, argEnv) + s.kv, s.kvQuant = benchKVLabel(k, v) + if m := strings.TrimSpace(cfg["MODEL"]); m != "" { + if p, err := resolveServeModelPath(m); err == nil { + if g, err := ggufMeta(p); err == nil { + s.hybrid = ggufHybrid(&g) + } + } + } + return s +} + +// benchKVLabel nomme les types de cache (« f16 », « q8_0/f16 »…) et dit s'ils +// sont quantifiés — rang 2 et plus de kvFidelity : bf16 n'en est pas. +func benchKVLabel(k, v string) (string, bool) { + norm := func(s string) string { + if s = strings.ToLower(strings.TrimSpace(s)); s == "" { + return "f16" + } + return s + } + k, v = norm(k), norm(v) + rk, _ := kvFidelity(k) + rv, _ := kvFidelity(v) + label := k + if k != v { + label = k + "/" + v + } + return label, max(rk, rv) >= 2 +} + +// payload : une requête de chat du bench. Échantillonnage et raisonnement du +// preset, comme le chat ; seed fixe pour que deux passages se comparent. +// cache : seuls les tours qui PROLONGENT la discussion en profondeur reprennent +// le cache — le reste mesure un prefill réellement calculé. +func (s benchSetup) payload(msgs []Message, maxTokens int, cache bool) map[string]any { + p := map[string]any{ + "model": "loki", + "messages": msgs, + "max_tokens": maxTokens, + "stream": false, + "temperature": 0.7, + "seed": benchSeed, + "cache_prompt": cache, + } + applySamplingFrom(p, s.cfg) + if s.effort != "" { + p["reasoning_effort"] = s.effort + } + if s.kwargs != nil { + p["chat_template_kwargs"] = s.kwargs + } + return p +} + +// benchDepthFor choisit la profondeur : la moitié du contexte, plafonnée, et +// assez basse pour que les tours ajoutés y tiennent encore (préchauffage compris, +// par prudence). Trop petit : la phase est sautée, pas ratée. +func benchDepthFor(nCtx int, cpuPlaced bool, warm int) (int, string) { + if nCtx <= 0 { + return 0, "taille de contexte inconnue" + } + d := min(nCtx/2, benchDepthMax) + if cpuPlaced { + d = min(d, benchDepthCPU) + } + d = min(d, nCtx-benchTurns*(benchAppendTokens+benchGenTokens)-benchGenTokens-warm-benchMargin) + if d < benchDepthMin { + return 0, fmt.Sprintf("contexte de %d jetons trop petit pour la mesure en profondeur", nCtx) + } + return d, "" +} + +// benchSlice coupe dans text, à partir de off, environ n octets arrêtés à une +// fin de ligne — jamais au milieu d'un caractère. "" : corpus épuisé. +func benchSlice(text string, off, n int) (string, int) { + if off >= len(text) || n <= 0 { + return "", off + } + end := off + n + if end >= len(text) { + return text[off:], len(text) + } + if i := strings.LastIndexByte(text[off:end], '\n'); i > n/2 { + end = off + i + 1 + } + for end > off && !utf8.RuneStart(text[end]) { + end-- + } + return text[off:end], end +} + +// benchLineStart : le début de la ligne qui suit off (off s'il en commence une). +func benchLineStart(text string, off int) int { + if off <= 0 { + return 0 + } + if off >= len(text) { + return len(text) + } + if text[off-1] == '\n' { + return off + } + if i := strings.IndexByte(text[off:], '\n'); i >= 0 { + return off + i + 1 + } + return len(text) +} + +// Consignes au modèle. La ligne courte demande de la prose, la profondeur du +// code : l'acceptation du brouillon diffère de l'un à l'autre, on mesure les deux. +const ( + benchAskProse = "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics." + benchAskFirst = "Résume en quelques phrases le rôle de ce fichier." + benchAskNext = "Écris en JavaScript une fonction courte inspirée de cette partie, sans explication." +) + +// --- le protocole ---------------------------------------------------------------- + +// benchRun déroule le protocole contre un moteur. Erreur = une phase de mesure +// a échoué : rien n'est à enregistrer. Une phase en profondeur interrompue par +// son budget ou par la taille du contexte rend un résultat PARTIEL, dit tel. +func benchRun(ctx context.Context, e benchEngine, opts benchOpts, s benchSetup, corp benchCorpora, progress benchProgress) (*benchResult, error) { + if opts.Prompt <= 0 { + opts.Prompt = 2000 + } + if opts.Predict <= 0 { + opts.Predict = 300 + } + if err := e.idle(ctx); err != nil { + return nil, err + } + steps, step := 2, 0 + if opts.full() { + steps += 1 + benchTurns + } + next := func(phase string) { + step++ + if progress != nil { + progress(phase, step, steps) + } + } + t0 := time.Now() + nCtx, build := e.props(ctx) + if nCtx <= 0 { + nCtx, _ = strconv.Atoi(strings.TrimSpace(s.cfg["CTX"])) + } + mode := benchModeQuick + if opts.full() { + mode = benchModeFull + } + res := &benchResult{Mode: mode, Protocol: benchProtocol, KV: s.kv, KVQuantized: s.kvQuant, Engine: build, GPUs: s.gpus} + + sample, _ := benchSlice(corp.code, 0, benchSampleBytes) + codeRatio := e.bytesPerToken(ctx, sample) + proseRatio := e.bytesPerToken(ctx, corp.prose) + + // 1. Préchauffage, jeté : premières allocations, graphes CUDA, pages du + // modèle — sans lui, la première mesure paie l'installation. Pris en fin de + // corpus, loin de ce que relira la profondeur. + next("préchauffage") + warm := 2*s.ub + benchWarmGen + warmText, _ := benchSlice(corp.code, benchLineStart(corp.code, len(corp.code)-int(float64(2*s.ub)*codeRatio)), len(corp.code)) + if _, err := e.chat(ctx, s.payload([]Message{{Role: "user", Content: warmText + "\n\n" + benchAskFirst}}, benchWarmGen, false)); err != nil { + return nil, fmt.Errorf("préchauffage : %w", err) + } + + // 2. La ligne courte, celle des versions précédentes : de la prose, puis du + // code inédit pour atteindre la taille visée — plus de corpus tuilé, qu'un + // brouillon n-gram recopiait. + next(fmt.Sprintf("ligne courte (%d + %d jetons)", opts.Prompt, opts.Predict)) + prompt := corp.prose + if want := int(float64(opts.Prompt) * proseRatio); want < len(prompt) { + prompt, _ = benchSlice(prompt, 0, want) + } else if rest := opts.Prompt - int(float64(len(prompt))/proseRatio); rest > 0 { + chunk, _ := benchSlice(corp.code, benchLineStart(corp.code, len(corp.code)/2), int(float64(rest)*codeRatio)) + prompt += "\n\n" + chunk + } + quick, err := e.chat(ctx, s.payload([]Message{{Role: "user", Content: strings.TrimSpace(prompt) + benchAskProse}}, opts.Predict, false)) + if err != nil { + return nil, fmt.Errorf("ligne courte : %w", err) + } + q := quick.turn + res.PromptN, res.PromptMs, res.PromptPerSecond = q.New, q.PromptMs, q.PromptPerSecond + res.PredictedN, res.PredictedMs, res.PredictedPerSec = q.PredictedN, q.PredictedMs, q.PredictedPerSec + res.Draft = q.Draft + if !opts.full() { + res.Elapsed = time.Since(t0).Seconds() + return res, nil + } + + // 3. La profondeur. + d, why := benchDepthFor(nCtx, s.cpuPlaced, warm) + depth := &benchDepth{Ctx: nCtx, Target: d, Reuse: -1} + res.Depth = depth + if why != "" { + depth.Skipped = why + res.Elapsed = time.Since(t0).Seconds() + return res, nil + } + if err := benchDepthRun(ctx, e, s, corp.code, codeRatio, depth, next); err != nil { + return nil, err + } + res.Elapsed = time.Since(t0).Seconds() + return res, nil +} + +// benchTimedOut : la requête a dépassé SON budget, le bench n'a pas été annulé. +func benchTimedOut(ctx context.Context, err error) bool { + return errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil +} + +// benchDepthRun : prefill à froid à D, puis benchTurns tours qui prolongent la +// même discussion avec du code jamais vu. +func benchDepthRun(ctx context.Context, e benchEngine, s benchSetup, code string, ratio float64, depth *benchDepth, next func(string)) error { + next(fmt.Sprintf("prefill à froid (%d jetons)", depth.Target)) + prefix, off := benchSlice(code, 0, int(float64(depth.Target)*ratio)) + msgs := []Message{{Role: "user", Content: "Voici un fichier source de l'interface web de Loki :\n\n```html\n" + prefix + "\n```\n\n" + benchAskFirst}} + io0, rss0, ioOK := engineIOSample() + cold, err := e.chat(ctx, s.payload(msgs, benchGenTokens, false)) + if benchTimedOut(ctx, err) { + depth.Partial = fmt.Sprintf("prefill à froid interrompu après %s", benchReqTimeout) + return nil + } + if err != nil { + return fmt.Errorf("prefill à froid : %w", err) + } + ct := cold.turn + depth.Cold = &ct + prev, answer := cold.promptTotal(), cold.content + var newN, gen int + var promptMs, genMs float64 + for r := 1; r <= benchTurns; r++ { + // Le tour suivant doit tenir : prompt précédent + sa réponse + les jetons + // neufs (estimés large) + la génération et la marge. Sinon on s'arrête — + // une mesure qui déborde déclencherait une erreur, ou pire un décalage. + if prev+2*benchGenTokens+benchAppendTokens*5/4+benchMargin > depth.Ctx { + depth.Partial = fmt.Sprintf("contexte plein après %d tour(s)", r-1) + break + } + chunk, nextOff := benchSlice(code, off, int(float64(benchAppendTokens)*ratio)) + if chunk == "" { + depth.Partial = fmt.Sprintf("corpus épuisé après %d tour(s)", r-1) + break + } + off = nextOff + next(fmt.Sprintf("tour %d/%d en profondeur", r, benchTurns)) + msgs = append(msgs, + Message{Role: "assistant", Content: answer}, + Message{Role: "user", Content: "Suite du fichier :\n\n```html\n" + chunk + "\n```\n\n" + benchAskNext}) + rep, err := e.chat(ctx, s.payload(msgs, benchGenTokens, true)) + if benchTimedOut(ctx, err) { + depth.Partial = fmt.Sprintf("tour %d interrompu après %s", r, benchReqTimeout) + break + } + if err != nil { + return fmt.Errorf("tour %d en profondeur : %w", r, err) + } + t := rep.turn + t.Expected = prev + depth.Turns = append(depth.Turns, t) + depth.Draft = depth.Draft.add(t.Draft) + newN += t.New + promptMs += t.PromptMs + gen += t.PredictedN + genMs += t.PredictedMs + prev, answer = rep.promptTotal(), rep.content + } + if promptMs > 0 { + depth.CachedPerSec = float64(newN) / (promptMs / 1000) + } + if genMs > 0 { + depth.DecodePerSec = float64(gen) / (genMs / 1000) + } + depth.Reuse, depth.ReuseNote = benchReuse(depth.Turns, s.hybrid) + if io1, rss1, ok := engineIOSample(); ok && ioOK && gen > 0 { + depth.Hint = benchThrashHint(io1-io0, gen, max(rss0, rss1)) + } + return nil +} + +// benchReuse : la part du prompt précédent reprise du cache, et ce qu'elle veut +// dire. Un constat, jamais un échec : un hybride reprend légitimement au +// dernier point de contrôle. +func benchReuse(turns []benchTurn, hybrid bool) (float64, string) { + var cached, expected int + full := true + for _, t := range turns { + if t.Cached < 0 || t.Expected <= 0 { + continue + } + cached += min(t.Cached, t.Expected) + expected += t.Expected + if t.Cached < t.Expected-benchReuseSlack { + full = false + } + } + if expected == 0 { + return -1, "le moteur ne dit pas combien de jetons il reprend du cache" + } + ratio := float64(cached) / float64(expected) + switch { + case full: + return ratio, "" + case hybrid: + return ratio, "reprise partielle : modèle hybride, le cache ne reprend qu'aux points de contrôle" + default: + return ratio, "reprise partielle : le gabarit réécrit sans doute la fin de l'historique (raisonnement retiré)" + } +} + +// benchThrashHint : un moteur qui relit beaucoup le disque pendant les tours +// n'a pas le modèle en RAM. Indication seulement (seuil non calibré). +func benchThrashHint(read int64, gen int, rss int64) string { + if read <= 0 || gen <= 0 { + return "" + } + per := read * 256 / int64(gen) + if per <= benchThrashBytes { + return "" + } + return fmt.Sprintf("possible thrash : %.1f Go relus du disque par 256 jetons générés (RSS %.1f Go) — le modèle ne tient sans doute pas en RAM", + float64(per)/(1<<30), float64(rss)/(1<<30)) +} + +// --- entrée -------------------------------------------------------------------- + +// runBench mesure le moteur local et enregistre le résultat s'il est complet. +// L'appelant tient le verrou de génération (benchStart) : aucun tour de chat +// ni tâche ne s'intercale. +func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*benchResult, error) { // Un bench mesure le moteur LOCAL : sur un preset externe, healthCheck dit // « prêt » sans moteur, et la mesure taperait un port arrêté ou un moteur // resté en vie — attribuée à tort à ce preset. @@ -54,116 +762,86 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) { if !healthCheck() { return nil, fmt.Errorf("serveur injoignable sur :%d", port) } - // Le bench prend le slot de la conversation : une fois fini, on l'efface - // pour qu'elle soit rechargée depuis le cache RAM (llm_slots.go). - defer engineSideJob()() - if nPrompt <= 0 { - nPrompt = 2000 - } - // 1 word ≈ 1.3 tokens roughly. Tile the varied corpus until we exceed - // nPrompt, then truncate to characters so the server tokenises a passage - // close to the requested size. - corpusWords := strings.Fields(benchCorpus) - target := nPrompt * 5 // ~5 chars/token gives a generous over-estimate - var b strings.Builder - for b.Len() < target { - for _, w := range corpusWords { - b.WriteString(w) - b.WriteByte(' ') - if b.Len() >= target { - break - } + cfg := ReadConfig() + argEnv := map[string]string{} + for _, k := range benchArgEnv { + if v := os.Getenv(k); v != "" { + argEnv[k] = v } } - prompt := strings.TrimSpace(b.String()) - // Use the same endpoint your real chat hits, so the comparison is honest - // (chat template, reasoning, OpenAI-compat layer all included). - payload := map[string]any{ - "model": "loki", - "messages": []Message{{Role: "user", Content: prompt + "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics."}}, - "max_tokens": nPredict, - "stream": false, - "temperature": 0.7, - "cache_prompt": false, - } - body, _ := json.Marshal(payload) - url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", port) - t0 := time.Now() - req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - authHeader(req) - client := &http.Client{Timeout: 5 * time.Minute} - defer engineRequestStart()() // avant l'effacement, différé plus haut - resp, err := client.Do(req) + setup := benchSetupFrom(cfg, argEnv) + eng := benchEngine{base: fmt.Sprintf("http://localhost:%d", port), auth: resolveChatEndpoint().auth, client: http.DefaultClient} + // Le bench prend le slot de la conversation : une fois fini (erreur et + // annulation comprises), on l'efface si c'est sans risque, pour que son état + // de 30k jetons n'évince pas la conversation du cache RAM (llm_slots.go). + defer engineSideJob()() + res, err := benchRun(ctx, eng, opts, setup, defaultBenchCorpora(), progress) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { - engineServed() // le slot porte désormais le bench : l'effacer a un sens - } - var parsed struct { - Timings struct { - PromptN int `json:"prompt_n"` - PromptMs float64 `json:"prompt_ms"` - PromptPerSecond float64 `json:"prompt_per_second"` - PredictedN int `json:"predicted_n"` - PredictedMs float64 `json:"predicted_ms"` - PredictedPerSec float64 `json:"predicted_per_second"` - } `json:"timings"` - Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - } `json:"usage"` - } - if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { - return nil, err - } - // /v1/chat/completions may report timings at top level or omit them; if - // missing, fall back to wall-clock derived from `usage` so we always show - // numbers comparable to what chat displays in its label. - if parsed.Timings.PredictedN == 0 && parsed.Usage.CompletionTokens > 0 { - elapsed := time.Since(t0).Seconds() - parsed.Timings.PromptN = parsed.Usage.PromptTokens - parsed.Timings.PredictedN = parsed.Usage.CompletionTokens - // Distribute elapsed time using a rough split (prefill is usually <20% at this size). - parsed.Timings.PromptMs = elapsed * 1000 * 0.15 - parsed.Timings.PredictedMs = elapsed * 1000 * 0.85 - if parsed.Timings.PromptMs > 0 { - parsed.Timings.PromptPerSecond = float64(parsed.Timings.PromptN) / (parsed.Timings.PromptMs / 1000) - } - if parsed.Timings.PredictedMs > 0 { - parsed.Timings.PredictedPerSec = float64(parsed.Timings.PredictedN) / (parsed.Timings.PredictedMs / 1000) - } - } - elapsed := time.Since(t0).Seconds() - t := parsed.Timings - res := &benchResult{ - PromptN: t.PromptN, PromptMs: t.PromptMs, PromptPerSecond: t.PromptPerSecond, - PredictedN: t.PredictedN, PredictedMs: t.PredictedMs, PredictedPerSec: t.PredictedPerSec, - Elapsed: elapsed, - } // Trace dans la télémétrie : le bench a pris le slot, la perte de cache du // tour suivant lui revient (perf_log.go). Cache inconnu : cache_prompt:false. - perfRecord(perfRec{Kind: perfBench, Complete: true, Total: t.PromptN, New: t.PromptN, - PPms: t.PromptMs, PPtps: t.PromptPerSecond, Gen: t.PredictedN, TGtps: t.PredictedPerSec}, nil) - saveLastBench(res) - saveBenchForActivePreset(res) + perfRecord(perfRec{Kind: perfBench, Complete: true, Total: res.PromptN, New: res.PromptN, + PPms: res.PromptMs, PPtps: res.PromptPerSecond, Gen: res.PredictedN, TGtps: res.PredictedPerSec}, nil) + if benchSavable(res) { + saveLastBench(res, cfg) + saveBenchForActivePreset(res, cfg) + } return res, nil } +// benchSavable : seul un résultat COMPLET est enregistré — une phase partielle +// se montre, mais ne s'affiche pas plus tard comme la mesure de ce preset. +func benchSavable(res *benchResult) bool { + return res != nil && (res.Depth == nil || res.Depth.Partial == "") +} + +// --- enregistrement -------------------------------------------------------------- + // savedBench is a benchResult plus the model it was run against and a timestamp. +// Fingerprint : configuration + cartes visées + protocole (benchFingerprint). +// Vide sur les mesures d'avant la v2, qui retombent sur le nom du modèle. type savedBench struct { - Result benchResult `json:"result"` - Model string `json:"model"` - At int64 `json:"at"` + Result benchResult `json:"result"` + Model string `json:"model"` + At int64 `json:"at"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +// benchFingerprint : l'empreinte de configuration (configFingerprint) ignore +// les clés « appareil », dont CUDA_VISIBLE_DEVICES — or l'ensemble et l'ordre +// des cartes pèsent plus que tout sur une machine à deux GPU. On l'y remet : +// celle du preset s'il l'impose, sinon celle de la machine (cur), que la +// bascule lui appliquerait (softPreservedKeys). Le build du moteur est +// enregistré à côté et affiché, sans entrer ici : le lire pour chaque preset +// de la liste lancerait le binaire. +func benchFingerprint(cfg, cur map[string]string) string { + gpus := strings.TrimSpace(cfg["CUDA_VISIBLE_DEVICES"]) + if gpus == "" { + gpus = strings.TrimSpace(cur["CUDA_VISIBLE_DEVICES"]) + } + return configFingerprint(map[string]string{ + "config": configFingerprint(cfg), + "gpus": gpus, + "protocol": strconv.Itoa(benchProtocol), + }) +} + +func newSavedBench(res *benchResult, cfg map[string]string) savedBench { + r := *res + if r.Depth != nil { + d := *r.Depth + d.Hint = "" // indication du moment, pas une mesure + r.Depth = &d + } + return savedBench{Result: r, Model: filepath.Base(cfg["MODEL"]), At: time.Now().Unix(), + Fingerprint: benchFingerprint(cfg, cfg)} } // saveLastBench enregistre le dernier benchmark (best-effort) pour que l'UI // puisse l'afficher sans le relancer. -func saveLastBench(res *benchResult) { - sb := savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} - _ = putJSON(bkState, "last_bench", sb) +func saveLastBench(res *benchResult, cfg map[string]string) { + _ = putJSON(bkState, "last_bench", newSavedBench(res, cfg)) } // loadLastBench relit le benchmark enregistré, ou nil s'il n'y en a pas. @@ -175,11 +853,16 @@ func loadLastBench() *savedBench { return &sb } -// benchMatchesPreset : le bench a-t-il été mesuré sur le modèle ACTUEL du -// preset ? Les benchs sont rangés par id de preset : sans ce contrôle, un -// preset dont le modèle a changé affichait les mesures d'un autre modèle. -// Repris d'AJEAN 0.16.3. -func benchMatchesPreset(sb savedBench, cfg map[string]string) bool { +// benchMatchesPreset : le bench a-t-il été mesuré sur la configuration ACTUELLE +// du preset ? Les benchs sont rangés par id de preset : sans ce contrôle, un +// preset dont le modèle (ou les cartes, le contexte, EXTRA_ARGS…) a changé +// affichait les mesures d'une autre configuration. Une mesure d'avant +// l'empreinte retombe sur le nom du modèle (repris d'AJEAN 0.16.3), pour que +// les pastilles existantes ne disparaissent pas. +func benchMatchesPreset(sb savedBench, cfg, cur map[string]string) bool { + if sb.Fingerprint != "" { + return sb.Fingerprint == benchFingerprint(cfg, cur) + } m := strings.TrimSpace(cfg["MODEL"]) return m != "" && sb.Model == filepath.Base(m) } @@ -206,7 +889,7 @@ func loadBenchStore() map[string]savedBench { // saveBenchForActivePreset records res under the name of the currently active // preset (celui qui correspond à la configuration active). Sans effet si aucun // preset ne correspond — le benchmark reste enregistré par saveLastBench. -func saveBenchForActivePreset(res *benchResult) { +func saveBenchForActivePreset(res *benchResult, cfg map[string]string) { list, err := ListPresets() if err != nil { return @@ -222,35 +905,90 @@ func saveBenchForActivePreset(res *benchResult) { return } m := loadBenchStore() - m[id] = savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} + m[id] = newSavedBench(res, cfg) _ = putJSON(bkState, "bench_presets", m) } +// --- ligne de commande ------------------------------------------------------------- + +// cmdBench : « loki bench [N] [PROMPT] [--full] ». Même protocole que +// l'interface. Le verrou de génération est celui de CE processus : face à un +// service web qui tourne, seule la vérification /slots (moteur occupé) protège +// la mesure — à lancer quand rien d'autre ne tourne. func cmdBench(args []string) error { - nPredict, nPrompt := 300, 2000 - if len(args) >= 1 && args[0] != "" { - if n, err := strconv.Atoi(args[0]); err == nil { - nPredict = n - } else { - return fmt.Errorf("argument invalide: %s", args[0]) + opts := benchOpts{Mode: benchModeQuick, Predict: 300, Prompt: 2000} + var pos []string + for _, a := range args { + if a == "--full" { + opts.Mode = benchModeFull + continue } + pos = append(pos, a) } - if len(args) >= 2 && args[1] != "" { - if n, err := strconv.Atoi(args[1]); err == nil { - nPrompt = n - } else { - return fmt.Errorf("argument invalide: %s", args[1]) + if len(pos) >= 1 && pos[0] != "" { + n, err := strconv.Atoi(pos[0]) + if err != nil { + return fmt.Errorf("argument invalide: %s", pos[0]) } + opts.Predict = n } - fmt.Printf("[bench] prompt ~%d tokens, n_predict=%d…\n", nPrompt, nPredict) - r, err := runBench(nPrompt, nPredict) + if len(pos) >= 2 && pos[1] != "" { + n, err := strconv.Atoi(pos[1]) + if err != nil { + return fmt.Errorf("argument invalide: %s", pos[1]) + } + opts.Prompt = n + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + release, err := conv.benchLease(cancel) + if err != nil { + return err + } + defer release() + fmt.Printf("[bench] %s — prompt ~%d tokens, n_predict=%d…\n", opts.Mode, opts.Prompt, opts.Predict) + r, err := runBench(ctx, opts, func(phase string, step, steps int) { + fmt.Printf(" [%d/%d] %s\n", step, steps, phase) + }) if err != nil { return err } fmt.Println() fmt.Printf(" %s %7.1f tok/s (%d tokens en %.2fs)\n", cyan("Prefill"), r.PromptPerSecond, r.PromptN, r.PromptMs/1000) - fmt.Printf(" %s %7.1f tok/s (%d tokens en %.2fs)\n", cyan("Decode "), r.PredictedPerSec, r.PredictedN, r.PredictedMs/1000) - fmt.Printf(" Total %.2fs\n", r.Elapsed) + fmt.Printf(" %s %7.1f tok/s (%d tokens en %.2fs)%s\n", cyan("Decode "), r.PredictedPerSec, r.PredictedN, r.PredictedMs/1000, benchDraftText(r.Draft)) + if d := r.Depth; d != nil { + switch { + case d.Skipped != "": + fmt.Printf(" Profondeur sautée : %s\n", d.Skipped) + case d.Cold != nil: + fmt.Printf(" %s %7.1f tok/s (%d tokens, à froid)\n", cyan("Prefill@D"), d.Cold.PromptPerSecond, d.Cold.New) + fmt.Printf(" %s %7.1f tok/s (jetons neufs, cache repris)\n", cyan("Prefill+ "), d.CachedPerSec) + fmt.Printf(" %s %7.1f tok/s%s\n", cyan("Decode@D "), d.DecodePerSec, benchDraftText(d.Draft)) + if d.Reuse >= 0 { + fmt.Printf(" Reprise du cache %.0f %%\n", d.Reuse*100) + } + } + for _, s := range []string{d.ReuseNote, d.Partial, d.Hint} { + if s != "" { + fmt.Printf(" ! %s\n", s) + } + } + } + kv := r.KV + if r.KVQuantized { + kv += " (quantifié — zone grise)" + } + fmt.Printf(" KV %s · moteur %s · total %.2fs\n", kv, r.Engine, r.Elapsed) + if !benchSavable(r) { + fmt.Println(" (résultat partiel : non enregistré)") + } fmt.Println() return nil } + +func benchDraftText(d *benchDraft) string { + if d == nil || d.N == 0 { + return "" + } + return fmt.Sprintf(" brouillon accepté %.0f %% (%d/%d)", float64(d.Accepted)*100/float64(d.N), d.Accepted, d.N) +} diff --git a/internal/loki/llm_bench_job.go b/internal/loki/llm_bench_job.go new file mode 100644 index 0000000..b22df11 --- /dev/null +++ b/internal/loki/llm_bench_job.go @@ -0,0 +1,235 @@ +package loki + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +// Le benchmark tourne en TÂCHE DE FOND. Le mode complet dure de une à cinq +// minutes : une requête HTTP synchrone était coupée bien avant par un reverse +// proxy (nginx d'Unraid à 60 s, Cloudflare à 100 s) ou par le délai de +// l'interface (30 s), pendant que le moteur, lui, continuait — et un second +// clic empilait un deuxième bench derrière le premier. Désormais : +// +// POST /api/bench lance (202 + id), 409 si le moteur est occupé +// GET /api/bench/status phase en cours, puis résultat ou erreur +// POST /api/bench/cancel annule (chaque requête au moteur porte le contexte) +// GET /api/bench/last dernier résultat enregistré +// +// Pendant toute la mesure, le bench tient le verrou de génération de la +// conversation : un message, une tâche planifiée ou une compaction reçoivent +// un refus clair au lieu d'attendre des minutes dans la file du moteur. Les +// clients /v1 reçoivent un 503 avec Retry-After (llm_oai.go). Restent hors du +// verrou les appels annexes déclenchés par la fin d'un tour (titre, mémoire) : +// le verrou les précède de toute façon. + +var errBenchBusy = errors.New("un benchmark occupe le moteur — attends sa fin ou annule-le") + +// benchLease prend le verrou de génération pour un benchmark. cancel est +// branché sur le bouton stop du chat, comme pour une tâche planifiée. La +// fonction rendue le libère ; si un Reset l'a déjà rendu entre-temps (epoch +// changé), elle ne touche pas au tour qui a pu démarrer depuis. +func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.Generating { + return nil, ErrBusy + } + c.Generating, c.benching, c.cancel = true, true, cancel + epoch := c.epoch + var once sync.Once + return func() { + once.Do(func() { + c.mu.Lock() + c.benching = false + if c.epoch == epoch { + c.Generating = false + c.cancel = nil + } + c.mu.Unlock() + }) + }, nil +} + +// benchJob : le benchmark en cours ou le dernier terminé (un seul à la fois). +var benchJob struct { + mu sync.Mutex + seq int + running bool + id string + mode string + phase string + step int + steps int + started time.Time + finished time.Time + cancel context.CancelFunc + result *benchResult + err string + canceled bool +} + +// benchRunning : un benchmark de ce processus est-il en cours ? +func benchRunning() bool { + benchJob.mu.Lock() + defer benchJob.mu.Unlock() + return benchJob.running +} + +// benchRunner est runBench, remplaçable dans les tests. +var benchRunner = runBench + +// benchStart lance un benchmark en arrière-plan. code : le statut HTTP à +// renvoyer en cas de refus. +func benchStart(opts benchOpts) (id string, code int, err error) { + // Hors verrou : healthCheck peut attendre le moteur quelques secondes, le + // suivi de progression ne doit pas rester bloqué derrière. + if externalActive() { + return "", http.StatusBadRequest, errors.New("benchmark indisponible : le preset actif est une API externe") + } + if !healthCheck() { + return "", http.StatusServiceUnavailable, errModelLoading + } + benchJob.mu.Lock() + defer benchJob.mu.Unlock() + if benchJob.running { + return "", http.StatusConflict, errors.New("un benchmark tourne déjà") + } + ctx, cancel := context.WithCancel(context.Background()) + release, err := conv.benchLease(cancel) + if err != nil { + cancel() + return "", http.StatusConflict, conv.busyReason() + } + benchJob.seq++ + id = fmt.Sprintf("b%d-%d", time.Now().Unix(), benchJob.seq) + benchJob.running, benchJob.id, benchJob.mode = true, id, opts.Mode + benchJob.phase, benchJob.step, benchJob.steps = "démarrage", 0, 0 + benchJob.started, benchJob.finished = time.Now(), time.Time{} + benchJob.cancel, benchJob.result, benchJob.err, benchJob.canceled = cancel, nil, "", false + go func() { + defer cancel() + defer release() + res, err := benchRunner(ctx, opts, func(phase string, step, steps int) { + benchJob.mu.Lock() + if benchJob.id == id { + benchJob.phase, benchJob.step, benchJob.steps = phase, step, steps + } + benchJob.mu.Unlock() + }) + benchJob.mu.Lock() + defer benchJob.mu.Unlock() + if benchJob.id != id { + return + } + benchJob.running, benchJob.finished, benchJob.cancel = false, time.Now(), nil + benchJob.result = res + if err != nil { + benchJob.canceled = ctx.Err() != nil + benchJob.err = err.Error() + if benchJob.canceled { + benchJob.err = "benchmark annulé" + } + } + }() + return id, http.StatusAccepted, nil +} + +// benchCancel annule le benchmark en cours. false : aucun ne tournait. +func benchCancel() bool { + benchJob.mu.Lock() + defer benchJob.mu.Unlock() + if !benchJob.running || benchJob.cancel == nil { + return false + } + benchJob.cancel() + return true +} + +// benchStatus : l'état à renvoyer à l'interface. +func benchStatus() map[string]any { + benchJob.mu.Lock() + defer benchJob.mu.Unlock() + st := map[string]any{"ok": true, "running": benchJob.running, "id": benchJob.id, "mode": benchJob.mode} + if benchJob.id == "" { + return st + } + st["phase"], st["step"], st["steps"] = benchJob.phase, benchJob.step, benchJob.steps + end := benchJob.finished + if benchJob.running { + end = time.Now() + } + st["elapsed_sec"] = end.Sub(benchJob.started).Seconds() + if !benchJob.running { + if benchJob.result != nil { + st["result"] = benchJob.result + st["saved"] = benchSavable(benchJob.result) + } + if benchJob.err != "" { + st["error"] = benchJob.err + st["canceled"] = benchJob.canceled + } + } + return st +} + +// handleBench lance un benchmark : POST seulement — il occupe le moteur des +// minutes durant, une balise sur une page tierce ne doit pas y suffire. +// Corps : {"mode":"quick"|"full", "prompt":N, "n":N}, tous facultatifs. +func handleBench(w http.ResponseWriter, r *http.Request) { + if !postOnly(w, r) { + return + } + var body struct { + Mode string `json:"mode"` + Prompt int `json:"prompt"` + N int `json:"n"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + opts := benchOpts{Mode: benchModeQuick, Prompt: 2000, Predict: 300} + if strings.EqualFold(strings.TrimSpace(body.Mode), benchModeFull) { + opts.Mode = benchModeFull + } + if body.Prompt > 0 { + opts.Prompt = min(body.Prompt, 32768) + } + if body.N > 0 { + opts.Predict = min(body.N, 4096) + } + id, code, err := benchStart(opts) + if err != nil { + sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, code, map[string]any{"ok": true, "id": id}) +} + +// handleBenchStatus : progression du benchmark en cours, ou issue du dernier. +func handleBenchStatus(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, benchStatus()) +} + +// handleBenchCancel annule le benchmark en cours. +func handleBenchCancel(w http.ResponseWriter, r *http.Request) { + if !postOnly(w, r) { + return + } + sendJSON(w, 200, map[string]any{"ok": benchCancel()}) +} + +// handleBenchLast returns the most recent persisted benchmark, or {ok:false} +// when none has been run yet. +func handleBenchLast(w http.ResponseWriter, r *http.Request) { + sb := loadLastBench() + if sb == nil { + sendJSON(w, 200, map[string]any{"ok": false}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At}) +} diff --git a/internal/loki/llm_bench_match_test.go b/internal/loki/llm_bench_match_test.go index 01dd104..101a093 100644 --- a/internal/loki/llm_bench_match_test.go +++ b/internal/loki/llm_bench_match_test.go @@ -5,13 +5,13 @@ import "testing" // Un bench n'est affiché que pour le modèle sur lequel il a été mesuré. func TestBenchMatchesPreset(t *testing.T) { sb := savedBench{Model: "Qwen3-27B-Q4_K_M.gguf"} - if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}) { + if !benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Qwen3-27B-Q4_K_M.gguf"}, nil) { t.Fatal("même modèle : le bench doit s'afficher") } - if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}) { + if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Autre.gguf"}, nil) { t.Fatal("autre modèle : le bench ne doit pas s'afficher") } - if benchMatchesPreset(sb, map[string]string{}) { + if benchMatchesPreset(sb, map[string]string{}, nil) { t.Fatal("preset sans modèle : pas de bench") } } diff --git a/internal/loki/llm_bench_test.go b/internal/loki/llm_bench_test.go new file mode 100644 index 0000000..762eb1f --- /dev/null +++ b/internal/loki/llm_bench_test.go @@ -0,0 +1,543 @@ +package loki + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + "unicode/utf8" +) + +// fakeBenchEngine simule llama-server pour le protocole du bench : un compte +// de jetons à 4 octets par jeton, un cache qui reprend le prompt précédent +// quand cache_prompt est vrai, et le journal des requêtes de chat reçues. +type fakeBenchEngine struct { + mu sync.Mutex + nCtx int + busy bool + failAt int // n° (1-based) de la requête de chat qui répond 500 ; 0 = aucune + noTiming bool // réponses sans timings + slowHot time.Duration // délai des tours qui reprennent le cache + prev int + calls []map[string]any +} + +func (f *fakeBenchEngine) handler(t *testing.T) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.Write([]byte(`{"status":"ok"}`)) + case "/slots": + fmt.Fprintf(w, `[{"id":0,"is_processing":%v}]`, f.busy) + case "/props": + fmt.Fprintf(w, `{"build_info":"b9999-test","total_slots":1,"default_generation_settings":{"n_ctx":%d}}`, f.nCtx) + case "/tokenize": + var b struct { + Content string `json:"content"` + } + json.NewDecoder(r.Body).Decode(&b) + toks := make([]int, len(b.Content)/4) + json.NewEncoder(w).Encode(map[string]any{"tokens": toks}) + case "/v1/chat/completions": + var p map[string]any + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + t.Errorf("corps illisible : %v", err) + } + f.mu.Lock() + f.calls = append(f.calls, p) + n := len(f.calls) + f.mu.Unlock() + if f.failAt == n { + http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError) + return + } + cache, _ := p["cache_prompt"].(bool) + if d := f.slowHot; cache && d > 0 { + select { + case <-time.After(d): + case <-r.Context().Done(): + return + } + } + total := 0 + for _, m := range p["messages"].([]any) { + c, _ := m.(map[string]any)["content"].(string) + total += len(c)/4 + 4 + } + cached := 0 + f.mu.Lock() + if cache { + cached = min(f.prev, total) + } + f.prev = total + f.mu.Unlock() + gen := int(p["max_tokens"].(float64)) + resp := map[string]any{ + "choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "réponse"}}}, + "usage": map[string]any{"prompt_tokens": total, "completion_tokens": gen}, + } + if !f.noTiming { + resp["timings"] = map[string]any{ + "prompt_n": total - cached, "prompt_ms": float64(total-cached) / 2, "prompt_per_second": 2000.0, + "predicted_n": gen, "predicted_ms": float64(gen) * 20, "predicted_per_second": 50.0, + "cache_n": cached, "draft_n": 10, "draft_n_accepted": 7, + } + } + json.NewEncoder(w).Encode(resp) + default: + http.NotFound(w, r) + } + }) +} + +func (f *fakeBenchEngine) start(t *testing.T) (benchEngine, *httptest.Server) { + srv := httptest.NewServer(f.handler(t)) + t.Cleanup(srv.Close) + return benchEngine{base: srv.URL, client: srv.Client()}, srv +} + +// testBenchCorpora : un code de 200 Ko sans aucune ligne répétée. +func testBenchCorpora() benchCorpora { + var b strings.Builder + for i := 0; b.Len() < 200<<10; i++ { + fmt.Fprintf(&b, "const ligne%06d = « contenu distinct %d » ;\n", i, i*7) + } + return benchCorpora{prose: benchCorpus, code: b.String()} +} + +func testBenchSetup() benchSetup { + return benchSetupFrom(map[string]string{"TOP_K": "40", "MIN_P": "0.05", "REASONING": "off"}, nil) +} + +// Le protocole complet, requête par requête : préchauffage, ligne courte et +// prefill à froid sans cache, puis trois tours qui prolongent la MÊME +// discussion avec le cache — et l'échantillonnage du preset partout. +func TestBenchProtocoleComplet(t *testing.T) { + f := &fakeBenchEngine{nCtx: 16384} + eng, _ := f.start(t) + var phases []string + res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), + func(phase string, step, steps int) { + phases = append(phases, fmt.Sprintf("%d/%d %s", step, steps, phase)) + }) + if err != nil { + t.Fatal(err) + } + if len(f.calls) != 6 { + t.Fatalf("%d requêtes de chat, attendu 6 (préchauffage, ligne courte, froid, 3 tours)", len(f.calls)) + } + wantCache := []bool{false, false, false, true, true, true} + wantMax := []float64{benchWarmGen, 300, benchGenTokens, benchGenTokens, benchGenTokens, benchGenTokens} + wantMsgs := []int{1, 1, 1, 3, 5, 7} + for i, p := range f.calls { + if p["cache_prompt"] != wantCache[i] { + t.Errorf("requête %d : cache_prompt=%v, attendu %v", i+1, p["cache_prompt"], wantCache[i]) + } + if p["max_tokens"] != wantMax[i] { + t.Errorf("requête %d : max_tokens=%v, attendu %v", i+1, p["max_tokens"], wantMax[i]) + } + msgs := p["messages"].([]any) + if len(msgs) != wantMsgs[i] { + t.Errorf("requête %d : %d messages, attendu %d", i+1, len(msgs), wantMsgs[i]) + } + for j, m := range msgs { + want := "user" + if j%2 == 1 { + want = "assistant" + } + if role := m.(map[string]any)["role"]; role != want { + t.Errorf("requête %d, message %d : rôle %v, attendu %s", i+1, j, role, want) + } + } + if p["top_k"] != float64(40) || p["min_p"] != 0.05 || p["seed"] != float64(benchSeed) { + t.Errorf("requête %d : échantillonnage du preset ou seed absents : %v", i+1, p) + } + if _, ok := p["ignore_eos"]; ok { + t.Errorf("requête %d : ignore_eos envoyé — génération hors distribution", i+1) + } + if kw, _ := p["chat_template_kwargs"].(map[string]any); kw["enable_thinking"] != false { + t.Errorf("requête %d : REASONING=off non transmis au gabarit : %v", i+1, p["chat_template_kwargs"]) + } + } + // Chaque tour apporte du code JAMAIS vu : aucun morceau ne se répète. + seen := map[string]bool{} + for _, m := range f.calls[5]["messages"].([]any) { + c := m.(map[string]any)["content"].(string) + if c != "réponse" && seen[c] { + t.Error("un morceau de corpus est renvoyé deux fois") + } + seen[c] = true + } + if len(phases) != 6 || !strings.HasPrefix(phases[5], "6/6 ") { + t.Errorf("progression inattendue : %v", phases) + } + d := res.Depth + if d == nil || d.Skipped != "" || d.Partial != "" || d.Cold == nil || len(d.Turns) != benchTurns { + t.Fatalf("phase en profondeur incomplète : %+v", d) + } + if want, _ := benchDepthFor(16384, false, 2*512+benchWarmGen); d.Target != want || d.Cold.New < want*9/10 { + t.Errorf("profondeur : visée %d (attendu %d), froid %d jetons", d.Target, want, d.Cold.New) + } + if d.Reuse < 0.99 || d.ReuseNote != "" { + t.Errorf("reprise du cache : %v %q, attendu complète", d.Reuse, d.ReuseNote) + } + if d.DecodePerSec != 50 || d.CachedPerSec != 2000 { + t.Errorf("agrégats : decode %v, prefill %v", d.DecodePerSec, d.CachedPerSec) + } + if d.Draft == nil || d.Draft.N != 30 || d.Draft.Accepted != 21 || res.Draft == nil || res.Draft.N != 10 { + t.Errorf("acceptation du brouillon : profondeur %+v, ligne courte %+v", d.Draft, res.Draft) + } + if res.Engine != "b9999-test" || res.Protocol != benchProtocol || res.Mode != benchModeFull || !benchSavable(res) { + t.Errorf("métadonnées : %+v", res) + } +} + +// Le mode rapide s'arrête à la ligne courte. +func TestBenchModeRapide(t *testing.T) { + f := &fakeBenchEngine{nCtx: 16384} + eng, _ := f.start(t) + res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, testBenchSetup(), testBenchCorpora(), nil) + if err != nil { + t.Fatal(err) + } + if len(f.calls) != 2 || res.Depth != nil || res.PredictedPerSec != 50 { + t.Fatalf("%d requêtes, profondeur %+v, decode %v", len(f.calls), res.Depth, res.PredictedPerSec) + } +} + +// Une réponse non-200 ou sans timings n'est jamais une mesure : erreur, donc +// rien d'enregistré (runBench n'enregistre qu'un résultat sans erreur). +func TestBenchEchecsNonEnregistres(t *testing.T) { + for _, c := range []struct { + name string + f *fakeBenchEngine + want string + }{ + {"500 sur la ligne courte", &fakeBenchEngine{nCtx: 16384, failAt: 2}, "ligne courte"}, + {"500 sur un tour", &fakeBenchEngine{nCtx: 16384, failAt: 5}, "tour 2"}, + {"sans timings", &fakeBenchEngine{nCtx: 16384, noTiming: true}, errBenchNoTimings.Error()}, + {"moteur occupé", &fakeBenchEngine{nCtx: 16384, busy: true}, "déjà une requête"}, + } { + t.Run(c.name, func(t *testing.T) { + eng, _ := c.f.start(t) + res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil) + if err == nil || res != nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("res=%v err=%v, attendu une erreur contenant %q", res, err, c.want) + } + if c.f.busy && len(c.f.calls) != 0 { + t.Error("moteur occupé : aucune requête ne devait partir") + } + }) + } +} + +// Contexte trop petit : la profondeur est sautée, la ligne courte reste. +func TestBenchProfondeurSautee(t *testing.T) { + f := &fakeBenchEngine{nCtx: 8192} + eng, _ := f.start(t) + res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil) + if err != nil { + t.Fatal(err) + } + if res.Depth == nil || res.Depth.Skipped == "" || len(f.calls) != 2 || !benchSavable(res) { + t.Fatalf("profondeur %+v, %d requêtes", res.Depth, len(f.calls)) + } +} + +// Un tour qui dépasse son budget rend un résultat PARTIEL, montré mais jamais +// enregistré. +func TestBenchBudgetPartiel(t *testing.T) { + old := benchReqTimeout + benchReqTimeout = 200 * time.Millisecond + t.Cleanup(func() { benchReqTimeout = old }) + f := &fakeBenchEngine{nCtx: 16384, slowHot: 2 * time.Second} + eng, _ := f.start(t) + res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil) + if err != nil { + t.Fatal(err) + } + if res.Depth == nil || !strings.Contains(res.Depth.Partial, "tour 1") || benchSavable(res) { + t.Fatalf("attendu un résultat partiel non enregistrable : %+v", res.Depth) + } +} + +// Annulé : erreur, rien d'enregistré. +func TestBenchAnnule(t *testing.T) { + f := &fakeBenchEngine{nCtx: 16384, slowHot: 5 * time.Second} + eng, _ := f.start(t) + ctx, cancel := context.WithCancel(context.Background()) + go func() { time.Sleep(300 * time.Millisecond); cancel() }() + res, err := benchRun(ctx, eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil) + if err == nil || res != nil { + t.Fatalf("annulation : res=%v err=%v", res, err) + } +} + +func TestBenchDepthFor(t *testing.T) { + warm := 2*512 + benchWarmGen + for _, c := range []struct { + ctx int + cpu bool + want int + skipped bool + comments string + }{ + {131072, false, benchDepthMax, false, "grand contexte : plafond"}, + {131072, true, benchDepthCPU, false, "poids sur CPU : plafond réduit"}, + {65536, false, 32768, false, "moitié du contexte"}, + {16384, false, 16384 - benchTurns*(benchAppendTokens+benchGenTokens) - benchGenTokens - warm - benchMargin, false, "les tours doivent tenir"}, + {8192, false, 0, true, "trop petit"}, + {0, false, 0, true, "inconnu"}, + } { + got, why := benchDepthFor(c.ctx, c.cpu, warm) + if got != c.want || (why != "") != c.skipped { + t.Errorf("%s : benchDepthFor(%d) = %d %q", c.comments, c.ctx, got, why) + } + if got > 0 && got+benchTurns*(benchAppendTokens+benchGenTokens)+benchGenTokens > c.ctx { + t.Errorf("%s : %d + tours déborde %d", c.comments, got, c.ctx) + } + } +} + +func TestBenchKVLabel(t *testing.T) { + for _, c := range []struct { + k, v, label string + quant bool + }{ + {"", "", "f16", false}, + {"f16", "", "f16", false}, + {"bf16", "bf16", "bf16", false}, + {"q8_0", "q8_0", "q8_0", true}, + {"q8_0", "", "q8_0/f16", true}, + {"", "q4_0", "f16/q4_0", true}, + } { + label, quant := benchKVLabel(c.k, c.v) + if label != c.label || quant != c.quant { + t.Errorf("benchKVLabel(%q,%q) = %q,%v ; attendu %q,%v", c.k, c.v, label, quant, c.label, c.quant) + } + } + // Le -ctk d'EXTRA_ARGS l'emporte, comme au lancement. + s := benchSetupFrom(map[string]string{"KV_TYPE": "f16", "EXTRA_ARGS": "-ctk q8_0 -ctv q8_0 --n-cpu-moe 20"}, nil) + if s.kv != "q8_0" || !s.kvQuant || !s.cpuPlaced { + t.Errorf("réglages tirés du preset : %+v", s) + } +} + +func TestBenchPayload(t *testing.T) { + for _, c := range []struct { + name string + cfg map[string]string + want map[string]any + none []string + }{ + {"échantillonnage du preset", map[string]string{"TEMP": "0.6", "TOP_P": "0.95", "TOP_K": "20"}, + map[string]any{"temperature": 0.6, "top_p": 0.95, "top_k": 20, "seed": benchSeed}, []string{"chat_template_kwargs", "reasoning_effort"}}, + {"sans réglage", map[string]string{}, + map[string]any{"temperature": 0.7, "seed": benchSeed}, []string{"top_k", "chat_template_kwargs"}}, + {"effort explicite", map[string]string{"REASONING_EFFORT": "high"}, + map[string]any{"reasoning_effort": "high"}, nil}, + } { + t.Run(c.name, func(t *testing.T) { + testHome(t) // effortResolve lit le modèle de la configuration + p := benchSetupFrom(c.cfg, nil).payload([]Message{{Role: "user", Content: "x"}}, 10, true) + for k, v := range c.want { + if fmt.Sprint(p[k]) != fmt.Sprint(v) { + t.Errorf("%s = %v, attendu %v", k, p[k], v) + } + } + for _, k := range append(c.none, "ignore_eos") { + if _, ok := p[k]; ok { + t.Errorf("%s ne devait pas être envoyé : %v", k, p[k]) + } + } + if p["cache_prompt"] != true || p["stream"] != false { + t.Errorf("cache_prompt/stream : %v", p) + } + }) + } +} + +func TestBenchSlice(t *testing.T) { + text := strings.Repeat("ééé ligne\n", 100) + off := 0 + for off < len(text) { + chunk, next := benchSlice(text, off, 37) + if chunk == "" || !utf8.ValidString(chunk) { + t.Fatalf("morceau invalide à %d : %q", off, chunk) + } + if next < len(text) && !strings.HasSuffix(chunk, "\n") { + t.Errorf("morceau coupé hors fin de ligne : %q", chunk) + } + off = next + } + if c, _ := benchSlice(text, len(text), 10); c != "" { + t.Error("corpus épuisé : morceau vide attendu") + } + if got := benchLineStart("ab\ncd", 1); got != 3 { + t.Errorf("benchLineStart = %d", got) + } +} + +func TestBenchReuse(t *testing.T) { + full := []benchTurn{{Cached: 1000, Expected: 1004}, {Cached: 3000, Expected: 3000}} + if r, note := benchReuse(full, false); r < 0.99 || note != "" { + t.Errorf("reprise complète : %v %q", r, note) + } + part := []benchTurn{{Cached: 512, Expected: 3000}} + if _, note := benchReuse(part, true); !strings.Contains(note, "hybride") { + t.Errorf("hybride : %q", note) + } + if _, note := benchReuse(part, false); !strings.Contains(note, "gabarit") { + t.Errorf("non hybride : %q", note) + } + if r, note := benchReuse([]benchTurn{{Cached: -1, Expected: 3000}}, false); r != -1 || note == "" { + t.Errorf("inconnu : %v %q", r, note) + } +} + +func TestBenchThrashHint(t *testing.T) { + if h := benchThrashHint(100<<20, 256, 0); h != "" { + t.Errorf("100 Mo / 256 jetons : pas d'indication attendue, %q", h) + } + if h := benchThrashHint(3<<30, 768, 60<<30); !strings.Contains(h, "thrash") { + t.Errorf("1 Go / 256 jetons : indication attendue, %q", h) + } +} + +// Une mesure enregistrée ne s'affiche que pour la configuration et les cartes +// sur lesquelles elle a été prise ; une mesure d'avant l'empreinte retombe sur +// le nom du modèle. +func TestBenchEmpreinte(t *testing.T) { + cfg := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"} + res := &benchResult{PromptPerSecond: 1, Depth: &benchDepth{Hint: "possible thrash"}} + sb := newSavedBench(res, cfg) + if sb.Fingerprint == "" || sb.Result.Depth.Hint != "" || res.Depth.Hint == "" { + t.Fatalf("enregistrement : %+v (l'indication ne s'enregistre pas, le résultat montré la garde)", sb) + } + preset := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768"} + if !benchMatchesPreset(sb, preset, cfg) { + t.Error("même configuration, mêmes cartes : la pastille doit s'afficher") + } + if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Q.gguf", "CTX": "65536"}, cfg) { + t.Error("contexte différent : pastille masquée attendue") + } + if benchMatchesPreset(sb, preset, map[string]string{"CUDA_VISIBLE_DEVICES": "1,0"}) { + t.Error("cartes dans un autre ordre : pastille masquée attendue") + } + own := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"} + if !benchMatchesPreset(sb, own, map[string]string{"CUDA_VISIBLE_DEVICES": "0"}) { + t.Error("preset qui impose ses cartes : ce sont elles qui comptent") + } + legacy := savedBench{Model: "Q.gguf"} + if !benchMatchesPreset(legacy, preset, cfg) { + t.Error("mesure d'avant l'empreinte : repli sur le nom du modèle") + } +} + +// Le lancement par l'interface : POST seulement, 409 pendant une génération, +// puis un seul bench à la fois, qui tient le verrou du chat et s'annule. +func TestBenchHandlers(t *testing.T) { + testHome(t) + f := &fakeBenchEngine{nCtx: 16384} + _, srv := f.start(t) + u, _ := url.Parse(srv.URL) + if err := WriteConfig(map[string]string{"PORT": u.Port()}); err != nil { + t.Fatal(err) + } + started := make(chan struct{}) + old := benchRunner + benchRunner = func(ctx context.Context, o benchOpts, p benchProgress) (*benchResult, error) { + p("mesure", 1, 2) + close(started) + <-ctx.Done() + return nil, ctx.Err() + } + t.Cleanup(func() { benchRunner = old }) + + do := func(method, path, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + switch path { + case "/api/bench": + handleBench(rec, req) + case "/api/bench/cancel": + handleBenchCancel(rec, req) + default: + handleBenchStatus(rec, req) + } + return rec + } + if rec := do(http.MethodGet, "/api/bench", ""); rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost { + t.Fatalf("GET : %d, Allow=%q", rec.Code, rec.Header().Get("Allow")) + } + + conv.mu.Lock() + conv.Generating = true + conv.mu.Unlock() + rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`) + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + if rec.Code != http.StatusConflict { + t.Fatalf("pendant une génération : %d, attendu 409", rec.Code) + } + + if rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`); rec.Code != http.StatusAccepted { + t.Fatalf("lancement : %d %s", rec.Code, rec.Body) + } + <-started + if rec := do(http.MethodPost, "/api/bench", `{}`); rec.Code != http.StatusConflict { + t.Errorf("second bench : %d, attendu 409", rec.Code) + } + if err := conv.StartTurn("bonjour", nil, Caps{}, 0); err != ErrBusy { + t.Errorf("tour de chat pendant le bench : %v, attendu ErrBusy", err) + } + if err := conv.busyReason(); err != errBenchBusy { + t.Errorf("motif du refus : %v", err) + } + if queued, err := conv.EnqueueOrStart("cid-bench", "x", nil, Caps{}, 0); queued || err == nil { + t.Errorf("message pendant le bench : mis en file=%v err=%v, attendu un refus", queued, err) + } + var st map[string]any + json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st) + if st["running"] != true || st["phase"] != "mesure" || st["mode"] != benchModeFull { + t.Errorf("statut en cours : %v", st) + } + if rec := do(http.MethodPost, "/api/bench/cancel", ""); rec.Code != 200 { + t.Fatalf("annulation : %d", rec.Code) + } + deadline := time.Now().Add(3 * time.Second) + for benchRunning() && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st) + if st["running"] != false || st["canceled"] != true { + t.Errorf("statut après annulation : %v", st) + } + if conv.isGenerating() { + t.Error("verrou de génération non rendu après le bench") + } +} + +// Les clients /v1 reçoivent un 503 à retenter pendant un benchmark. +func TestBenchRefuseV1(t *testing.T) { + benchJob.mu.Lock() + benchJob.running = true + benchJob.mu.Unlock() + t.Cleanup(func() { + benchJob.mu.Lock() + benchJob.running = false + benchJob.mu.Unlock() + }) + rec := httptest.NewRecorder() + oaiHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}"))) + if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") == "" { + t.Fatalf("pendant le bench : %d, Retry-After=%q", rec.Code, rec.Header().Get("Retry-After")) + } +} diff --git a/internal/loki/llm_oai.go b/internal/loki/llm_oai.go index 9737fee..fb354d0 100644 --- a/internal/loki/llm_oai.go +++ b/internal/loki/llm_oai.go @@ -119,6 +119,14 @@ func oaiHandler() http.Handler { // Complétion d'un client externe : elle prend le slot sous le nez de // la conversation. Notée pour nommer la perte de cache qui suit. if r.Method == http.MethodPost && strings.HasPrefix(p, "/v1/") { + // Pendant un benchmark, elle fausserait la mesure et attendrait des + // minutes derrière lui : refus explicite, à retenter. + if benchRunning() { + w.Header().Set("Retry-After", "30") + sendOAIError(w, http.StatusServiceUnavailable, + "benchmark en cours sur ce moteur — réessaie dans un moment", "server_error", "benchmark_running") + return + } perfNoteForeign() } lp.ServeHTTP(w, r) diff --git a/internal/loki/perf_log.go b/internal/loki/perf_log.go index c29a9b1..5d1ba83 100644 --- a/internal/loki/perf_log.go +++ b/internal/loki/perf_log.go @@ -101,6 +101,7 @@ type perfWire struct { PromptMs perfNum `json:"prompt_ms"` PromptPerSecond perfNum `json:"prompt_per_second"` PredictedN perfNum `json:"predicted_n"` + PredictedMs perfNum `json:"predicted_ms"` PredictedPerSec perfNum `json:"predicted_per_second"` CacheN perfNum `json:"cache_n"` DraftN perfNum `json:"draft_n"` diff --git a/internal/loki/run.go b/internal/loki/run.go index 6901dd6..8881ed0 100644 --- a/internal/loki/run.go +++ b/internal/loki/run.go @@ -135,7 +135,7 @@ Moteur (loki-engine) : enable | disable auto-démarrage au boot edit éditer la configuration dans $EDITOR switch [N] activer un preset de presets/ (interactif ou par numéro) - test | bench [N] vérifier que l'IA répond / mesurer prefill + decode tok/s + test | bench [N] [--full] vérifier que l'IA répond / mesurer prefill + decode (--full : en profondeur) vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer diff --git a/internal/loki/sys_loadpct_linux.go b/internal/loki/sys_loadpct_linux.go index 206f605..f3cc225 100644 --- a/internal/loki/sys_loadpct_linux.go +++ b/internal/loki/sys_loadpct_linux.go @@ -102,3 +102,27 @@ func modelTotalSize(path string) int64 { } return 0 } + +// engineIOSample : octets lus du disque (read_bytes) et mémoire résidente du +// moteur, pour l'indication « possible thrash » du benchmark. ok=false si le +// PID ou /proc est illisible : le bench s'en passe sans rien dire. +func engineIOSample() (read, rss int64, ok bool) { + pid := readServicePID() + if pid <= 0 { + return 0, 0, false + } + b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/io") + if err != nil { + return 0, 0, false + } + for _, line := range strings.Split(string(b), "\n") { + if v, found := strings.CutPrefix(line, "read_bytes:"); found { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return 0, 0, false + } + return n, procVmRSS(pid), true + } + } + return 0, 0, false +} diff --git a/internal/loki/sys_loadpct_other.go b/internal/loki/sys_loadpct_other.go index bcdd404..44c1cc1 100644 --- a/internal/loki/sys_loadpct_other.go +++ b/internal/loki/sys_loadpct_other.go @@ -5,3 +5,6 @@ package loki // Hors Linux, pas de /proc//io : le pourcentage de chargement n'est pas // mesurable, l'UI garde « chargement… » sans chiffre. func engineLoadPct() int { return -1 } + +// engineIOSample : pas de /proc//io, pas d'indication « possible thrash ». +func engineIOSample() (read, rss int64, ok bool) { return 0, 0, false } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index aeaf199..3ace792 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2471,7 +2471,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+ +
@@ -4435,8 +4437,11 @@ async function loadPresets(){ } if(x.bench){ const bt=document.createElement('span'); bt.className='btag'; - bt.title='prefill / decode — dernier bench de ce preset'; - bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'; + let tip='prefill / decode — dernier bench de ce preset'; + if(x.bench.depth_decode) tip+='\ndecode à '+x.bench.depth+' jetons : '+x.bench.depth_decode.toFixed(1)+' t/s'; + if(x.bench.kv) tip+='\ncache KV '+x.bench.kv+(x.bench.kv_quantized?' — quantifié (zone grise, pas une référence sans perte)':''); + bt.title=tip; + bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'+(x.bench.kv_quantized?' · KV '+x.bench.kv:''); meta.appendChild(bt); } info.appendChild(nm); @@ -4963,46 +4968,101 @@ async function loadAll(){ async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } function openBenchModal(){ showModal('bench-modal'); } function closeBenchModal(){ hideModal('bench-modal'); } -async function runBenchUI(){ - const btn = document.getElementById('btn-bench'); - const rerun = document.getElementById('bench-rerun'); - const body = document.getElementById('bench-body'); - openBenchModal(); - btn.disabled = true; btn.textContent = '⏳ bench…'; - rerun.disabled = true; - body.innerHTML = - '
' + +// Le bench tourne en tâche de fond côté serveur (llm_bench_job.go) : on le lance, +// puis on suit sa progression. Fermer la fenêtre ne l'arrête pas ; la rouvrir +// (bouton bench) reprend le suivi au lieu d'en lancer un second. +let benchPoll=null; +function benchButtons(busy){ + const btn=document.getElementById('btn-bench'); + btn.disabled=busy; btn.textContent=busy?'⏳ bench…':'bench'; + for(const id of ['bench-rerun','bench-full']){ const b=document.getElementById(id); if(b) b.disabled=busy; } + const c=document.getElementById('bench-cancel'); if(c) c.style.display=busy?'':'none'; +} +function benchSpinner(text){ + return '
' + '
⏳
' + - '
prompt 2000 tok + 300 decode
~10 secondes…
' + - '
'; + '
'+text+'
'; +} +function benchErr(msg){ return '
erreur : '+escHtml(msg||'?')+'
'; } +async function runBenchUI(mode){ + mode = mode==='full' ? 'full' : 'quick'; + const body=document.getElementById('bench-body'); + openBenchModal(); + benchButtons(true); + body.innerHTML=benchSpinner('démarrage…'); try{ - const r = await jget('/api/bench'); - if(!r.ok){ - body.innerHTML = '
erreur: '+r.error+'
'; + const st=await jget('/api/bench/status'); + if(st.running){ benchWatch(); return; } + }catch(e){} + let r; + try{ r=await jpost('/api/bench',{mode:mode}); }catch(e){ r={ok:false,error:e.message}; } + if(!r.ok){ body.innerHTML=benchErr(r.error); benchButtons(false); return; } + benchWatch(); +} +async function cancelBenchUI(){ try{ await jpost('/api/bench/cancel',{}); }catch(e){} } +function benchWatch(){ + clearTimeout(benchPoll); + const body=document.getElementById('bench-body'); + const tick=async()=>{ + let st; + try{ st=await jget('/api/bench/status'); } + catch(e){ benchPoll=setTimeout(tick,2000); return; } + if(st.running){ + const step=st.steps?' ('+st.step+'/'+st.steps+')':''; + const hint=st.mode==='full'?'
bench complet : 1 à 5 minutes, chat et tâches en pause':''; + body.innerHTML=benchSpinner(escHtml(st.phase||'…')+step+' · '+Math.round(st.elapsed_sec||0)+' s'+hint); + benchPoll=setTimeout(tick,1000); return; } - const x = r.result; - body.innerHTML = - '
' + - '
' + - '
Prefill
' + - '
'+x.prompt_per_second.toFixed(0)+'
' + - '
tok/s
' + - '
'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s
' + - '
' + - '
' + - '
Decode
' + - '
'+x.predicted_per_second.toFixed(1)+'
' + - '
tok/s
' + - '
'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s
' + - '
' + - '
' + - '
total '+x.elapsed_sec.toFixed(2)+'s
'; - } finally { - btn.disabled = false; btn.textContent = 'bench'; - rerun.disabled = false; + benchButtons(false); + if(st.error) body.innerHTML=benchErr(st.error); + else if(st.result) body.innerHTML=benchResultHTML(st.result, st.saved); + else body.innerHTML=''; loadPresets(); + }; + tick(); +} +function benchDraftTxt(d){ return d&&d.n ? Math.round(d.accepted*100/d.n)+' % ('+d.accepted+'/'+d.n+')' : 'n/a'; } +function benchCard(label, color, val, unit, sub){ + return '
' + + '
'+label+'
' + + '
'+val+'
' + + '
'+unit+'
' + + '
'+sub+'
'; +} +function benchResultHTML(x, saved){ + let h='
' + + benchCard('Prefill','var(--accent)',x.prompt_per_second.toFixed(0),'tok/s',x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s') + + benchCard('Decode','var(--ok)',x.predicted_per_second.toFixed(1),'tok/s',x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s') + + '
'; + const rows=[]; + if(x.draft) rows.push(['brouillon accepté (prose)', benchDraftTxt(x.draft)]); + const d=x.depth; + const notes=[]; + if(d){ + if(d.skipped) notes.push('profondeur sautée : '+d.skipped); + if(d.cold) rows.push(['prefill à froid · '+d.cold.new+' tok', d.cold.prompt_per_second.toFixed(0)+' tok/s']); + if(d.turns&&d.turns.length){ + rows.push(['prefill des tours suivants (cache)', d.cached_per_second.toFixed(0)+' tok/s']); + rows.push(['decode en profondeur', d.decode_per_second.toFixed(1)+' tok/s']); + rows.push(['reprise du cache', d.reuse>=0 ? Math.round(d.reuse*100)+' %' : 'n/a']); + rows.push(['brouillon accepté (code)', benchDraftTxt(d.draft)]); + } + for(const n of [d.reuse_note, d.partial, d.hint]) if(n) notes.push(n); } + if(rows.length){ + h+=''; + for(const r of rows) h+=''; + h+='
'+escHtml(r[0])+''+escHtml(r[1])+'
'; + } + for(const n of notes) h+='
'+escHtml(n)+'
'; + let foot='KV '+(x.kv||'?')+(x.kv_quantized?' (quantifié — zone grise)':''); + if(x.engine) foot+=' · moteur '+x.engine; + foot+=' · total '+x.elapsed_sec.toFixed(1)+'s'; + if(saved===false) foot+=' · résultat partiel, non enregistré'; + h+='
'+escHtml(foot)+'
'; + if(d&&d.cold) h+='
Le slot est effacé après le bench quand le moteur le permet : sinon, le prochain message reprend la conversation depuis le cache RAM (CACHE_RAM) ou la recalcule.
'; + return h; } async function switchTo(n,name,id){ if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index f5ebe37..b48de71 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -245,7 +245,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+ +
diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js index eb8ce0b..7586771 100644 --- a/internal/loki/ui/src/js/06-settings.js +++ b/internal/loki/ui/src/js/06-settings.js @@ -93,8 +93,11 @@ async function loadPresets(){ } if(x.bench){ const bt=document.createElement('span'); bt.className='btag'; - bt.title='prefill / decode — dernier bench de ce preset'; - bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'; + let tip='prefill / decode — dernier bench de ce preset'; + if(x.bench.depth_decode) tip+='\ndecode à '+x.bench.depth+' jetons : '+x.bench.depth_decode.toFixed(1)+' t/s'; + if(x.bench.kv) tip+='\ncache KV '+x.bench.kv+(x.bench.kv_quantized?' — quantifié (zone grise, pas une référence sans perte)':''); + bt.title=tip; + bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'+(x.bench.kv_quantized?' · KV '+x.bench.kv:''); meta.appendChild(bt); } info.appendChild(nm); diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 8798b06..236b8a0 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -1,45 +1,100 @@ function openBenchModal(){ showModal('bench-modal'); } function closeBenchModal(){ hideModal('bench-modal'); } -async function runBenchUI(){ - const btn = document.getElementById('btn-bench'); - const rerun = document.getElementById('bench-rerun'); - const body = document.getElementById('bench-body'); - openBenchModal(); - btn.disabled = true; btn.textContent = '⏳ bench…'; - rerun.disabled = true; - body.innerHTML = - '
' + +// Le bench tourne en tâche de fond côté serveur (llm_bench_job.go) : on le lance, +// puis on suit sa progression. Fermer la fenêtre ne l'arrête pas ; la rouvrir +// (bouton bench) reprend le suivi au lieu d'en lancer un second. +let benchPoll=null; +function benchButtons(busy){ + const btn=document.getElementById('btn-bench'); + btn.disabled=busy; btn.textContent=busy?'⏳ bench…':'bench'; + for(const id of ['bench-rerun','bench-full']){ const b=document.getElementById(id); if(b) b.disabled=busy; } + const c=document.getElementById('bench-cancel'); if(c) c.style.display=busy?'':'none'; +} +function benchSpinner(text){ + return '
' + '
⏳
' + - '
prompt 2000 tok + 300 decode
~10 secondes…
' + - '
'; + '
'+text+'
'; +} +function benchErr(msg){ return '
erreur : '+escHtml(msg||'?')+'
'; } +async function runBenchUI(mode){ + mode = mode==='full' ? 'full' : 'quick'; + const body=document.getElementById('bench-body'); + openBenchModal(); + benchButtons(true); + body.innerHTML=benchSpinner('démarrage…'); try{ - const r = await jget('/api/bench'); - if(!r.ok){ - body.innerHTML = '
erreur: '+r.error+'
'; + const st=await jget('/api/bench/status'); + if(st.running){ benchWatch(); return; } + }catch(e){} + let r; + try{ r=await jpost('/api/bench',{mode:mode}); }catch(e){ r={ok:false,error:e.message}; } + if(!r.ok){ body.innerHTML=benchErr(r.error); benchButtons(false); return; } + benchWatch(); +} +async function cancelBenchUI(){ try{ await jpost('/api/bench/cancel',{}); }catch(e){} } +function benchWatch(){ + clearTimeout(benchPoll); + const body=document.getElementById('bench-body'); + const tick=async()=>{ + let st; + try{ st=await jget('/api/bench/status'); } + catch(e){ benchPoll=setTimeout(tick,2000); return; } + if(st.running){ + const step=st.steps?' ('+st.step+'/'+st.steps+')':''; + const hint=st.mode==='full'?'
bench complet : 1 à 5 minutes, chat et tâches en pause':''; + body.innerHTML=benchSpinner(escHtml(st.phase||'…')+step+' · '+Math.round(st.elapsed_sec||0)+' s'+hint); + benchPoll=setTimeout(tick,1000); return; } - const x = r.result; - body.innerHTML = - '
' + - '
' + - '
Prefill
' + - '
'+x.prompt_per_second.toFixed(0)+'
' + - '
tok/s
' + - '
'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s
' + - '
' + - '
' + - '
Decode
' + - '
'+x.predicted_per_second.toFixed(1)+'
' + - '
tok/s
' + - '
'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s
' + - '
' + - '
' + - '
total '+x.elapsed_sec.toFixed(2)+'s
'; - } finally { - btn.disabled = false; btn.textContent = 'bench'; - rerun.disabled = false; + benchButtons(false); + if(st.error) body.innerHTML=benchErr(st.error); + else if(st.result) body.innerHTML=benchResultHTML(st.result, st.saved); + else body.innerHTML=''; loadPresets(); + }; + tick(); +} +function benchDraftTxt(d){ return d&&d.n ? Math.round(d.accepted*100/d.n)+' % ('+d.accepted+'/'+d.n+')' : 'n/a'; } +function benchCard(label, color, val, unit, sub){ + return '
' + + '
'+label+'
' + + '
'+val+'
' + + '
'+unit+'
' + + '
'+sub+'
'; +} +function benchResultHTML(x, saved){ + let h='
' + + benchCard('Prefill','var(--accent)',x.prompt_per_second.toFixed(0),'tok/s',x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s') + + benchCard('Decode','var(--ok)',x.predicted_per_second.toFixed(1),'tok/s',x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s') + + '
'; + const rows=[]; + if(x.draft) rows.push(['brouillon accepté (prose)', benchDraftTxt(x.draft)]); + const d=x.depth; + const notes=[]; + if(d){ + if(d.skipped) notes.push('profondeur sautée : '+d.skipped); + if(d.cold) rows.push(['prefill à froid · '+d.cold.new+' tok', d.cold.prompt_per_second.toFixed(0)+' tok/s']); + if(d.turns&&d.turns.length){ + rows.push(['prefill des tours suivants (cache)', d.cached_per_second.toFixed(0)+' tok/s']); + rows.push(['decode en profondeur', d.decode_per_second.toFixed(1)+' tok/s']); + rows.push(['reprise du cache', d.reuse>=0 ? Math.round(d.reuse*100)+' %' : 'n/a']); + rows.push(['brouillon accepté (code)', benchDraftTxt(d.draft)]); + } + for(const n of [d.reuse_note, d.partial, d.hint]) if(n) notes.push(n); } + if(rows.length){ + h+=''; + for(const r of rows) h+=''; + h+='
'+escHtml(r[0])+''+escHtml(r[1])+'
'; + } + for(const n of notes) h+='
'+escHtml(n)+'
'; + let foot='KV '+(x.kv||'?')+(x.kv_quantized?' (quantifié — zone grise)':''); + if(x.engine) foot+=' · moteur '+x.engine; + foot+=' · total '+x.elapsed_sec.toFixed(1)+'s'; + if(saved===false) foot+=' · résultat partiel, non enregistré'; + h+='
'+escHtml(foot)+'
'; + if(d&&d.cold) h+='
Le slot est effacé après le bench quand le moteur le permet : sinon, le prochain message reprend la conversation depuis le cache RAM (CACHE_RAM) ou la recalcule.
'; + return h; } async function switchTo(n,name,id){ if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index b8be343..9b32099 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -408,6 +408,7 @@ func handlePresets(w http.ResponseWriter, r *http.Request) { return } store := loadBenchStore() + cur := ReadConfig() out := []map[string]any{} for _, p := range list { item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active} @@ -430,12 +431,23 @@ func handlePresets(w http.ResponseWriter, r *http.Request) { item["reasoning"] = strings.ToLower(r) } } - if sb, ok := store[p.ID]; ok && benchMatchesPreset(sb, parseEnv(content)) { - item["bench"] = map[string]any{ + if sb, ok := store[p.ID]; ok && benchMatchesPreset(sb, parseEnv(content), cur) { + bench := map[string]any{ "prefill": sb.Result.PromptPerSecond, "decode": sb.Result.PredictedPerSec, "at": sb.At, } + // Types de cache à côté de la mesure : un KV quantifié n'est pas une + // référence sans perte, la pastille le dit. + if sb.Result.KV != "" { + bench["kv"] = sb.Result.KV + bench["kv_quantized"] = sb.Result.KVQuantized + } + if d := sb.Result.Depth; d != nil && d.DecodePerSec > 0 && d.Cold != nil { + bench["depth"] = d.Cold.New + bench["depth_decode"] = d.DecodePerSec + } + item["bench"] = bench } out = append(out, item) } @@ -1340,39 +1352,6 @@ func svcHandler(action string) http.HandlerFunc { // handleChat is the SSE proxy with tool-calling. The HTTP handler writes raw // data: lines matching what the embedded JS expects (delta.content, // delta.reasoning_content, delta.tool_used). -// handleBench runs `runBench` synchronously. Long enough (~30-60s) that we -// rely on the client side to show a spinner / disable the button. -func handleBench(w http.ResponseWriter, r *http.Request) { - nPrompt, nPredict := 2000, 300 - if v := r.URL.Query().Get("prompt"); v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - nPrompt = parsed - } - } - if v := r.URL.Query().Get("n"); v != "" { - if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { - nPredict = parsed - } - } - res, err := runBench(nPrompt, nPredict) - if err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - sendJSON(w, 200, map[string]any{"ok": true, "result": res}) -} - -// handleBenchLast returns the most recent persisted benchmark, or {ok:false} -// when none has been run yet. -func handleBenchLast(w http.ResponseWriter, r *http.Request) { - sb := loadLastBench() - if sb == nil { - sendJSON(w, 200, map[string]any{"ok": false}) - return - } - sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At}) -} - // chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E). // // `messages` et `ctx_used`, que les clients envoyaient du temps où l'historique diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index f6e37e2..38f9805 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -330,7 +330,9 @@ func newWebMux() *http.ServeMux { api("/api/start", svcHandler("start")) api("/api/stop", svcHandler("stop")) api("/api/restart", svcHandler("restart")) - api("/api/bench", handleBench) + api("/api/bench", handleBench) // POST : lance le benchmark en arrière-plan (llm_bench_job.go) + api("/api/bench/status", handleBenchStatus) // progression, puis résultat + api("/api/bench/cancel", handleBenchCancel) // POST : annule api("/api/bench/last", handleBenchLast) api("/api/perf/summary", handlePerfSummary) api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil