diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 7991eb2..15b33ab 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -198,7 +198,14 @@ func LoadConversation() { // cache de la discussion active change sous mu, avec le contenu (voir // convActivate). L'appelant NE doit PAS détenir mu. func (c *Conversation) loadFrom(id string, b []byte) { - c.Stop() + // Un benchmark n'appartient à aucune discussion : changer de fil ne l'arrête + // pas, et le verrou reste à lui jusqu'à sa fin (llm_bench_job.go). + c.mu.Lock() + bench := c.benching + c.mu.Unlock() + if !bench { + c.Stop() + } c.mu.Lock() if c == conv { convActiveRemember(id) @@ -211,8 +218,10 @@ func (c *Conversation) loadFrom(id string, b []byte) { c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement } go pruneChatImages() // plus rien ne référence les images de l'ancien fil - c.Generating = false - c.cancel = nil + if !c.benching { + c.Generating = false + c.cancel = nil + } c.epoch++ c.cond.Broadcast() c.mu.Unlock() @@ -1069,6 +1078,7 @@ func (c *Conversation) Reset() { c.ctxUsedLen, c.genPeak = 0, 0 c.epoch++ c.Generating = false + c.benching = false // Stop a annulé le bench : le verrou est rendu ici c.cancel = nil c.queued = nil // les messages en attente visaient le fil qu'on vient de vider c.cond.Broadcast() diff --git a/internal/loki/llm_bench.go b/internal/loki/llm_bench.go index 31786f4..763d438 100644 --- a/internal/loki/llm_bench.go +++ b/internal/loki/llm_bench.go @@ -236,15 +236,30 @@ func (e benchEngine) do(ctx context.Context, method, path string, payload any) ( 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 nil, &benchHTTPError{method: method, path: path, status: resp.Status, body: string(data)} } return data, err } +// benchHTTPError : une réponse non-200 du moteur. Le corps est gardé en entier : +// le refus d'un niveau de raisonnement cite les niveaux acceptés en FIN de +// message (llm_effort.go), bien au-delà de l'extrait affiché. +type benchHTTPError struct { + method, path, status, body string +} + +func (e *benchHTTPError) Error() string { + snip := strings.TrimSpace(e.body) + if len(snip) > 300 { + cut := 300 + for cut > 0 && !utf8.RuneStart(snip[cut]) { + cut-- // jamais au milieu d'un caractère + } + snip = snip[:cut] + } + return fmt.Sprintf("%s %s : %s %s", e.method, e.path, e.status, snip) +} + // 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, @@ -395,8 +410,12 @@ var benchArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V", // benchSetup : ce que le bench tire du preset, une fois. type benchSetup struct { cfg map[string]string - effort string + want string // niveau de raisonnement demandé par le preset + off bool // raisonnement interdit + effort string // niveau envoyé (want, ou la traduction apprise) kwargs map[string]any + retried bool // refus du niveau déjà rejoué une fois + learn func(want, got string) // retient la traduction (nil : tests) 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 @@ -412,7 +431,7 @@ func benchSetupFrom(cfg map[string]string, argEnv map[string]string) benchSetup 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, + s := benchSetup{cfg: cfg, want: want, off: off, 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 { @@ -476,6 +495,29 @@ func (s benchSetup) payload(msgs []Message, maxTokens int, cache bool) map[strin return p } +// ask envoie une requête de chat du bench. Un gabarit qui REFUSE le niveau de +// raisonnement du preset (Qwen3.8 ne connaît pas « high ») est traité comme +// dans le chat (runChatTools) : on rejoue une fois avec le niveau qu'il accepte, +// ou sans le champ, et la traduction est retenue pour ce modèle. Sans ça, le +// bench d'un processus neuf échouait sur un 500 là où le chat, lui, répond. +func (s *benchSetup) ask(ctx context.Context, e benchEngine, msgs []Message, maxTokens int, cache bool) (benchReply, error) { + r, err := e.chat(ctx, s.payload(msgs, maxTokens, cache)) + var he *benchHTTPError + if err == nil || s.retried || s.want == "" || !errors.As(err, &he) { + return r, err + } + fixed, ok := effortFromRejection(he.body, s.want) + if !ok { + return r, err + } + s.retried = true + if s.learn != nil { + s.learn(s.want, fixed) + } + s.effort, s.kwargs = fixed, reasoningTemplateKwargs(s.off, fixed) + return e.chat(ctx, s.payload(msgs, maxTokens, cache)) +} + // 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. @@ -580,11 +622,17 @@ func benchRun(ctx context.Context, e benchEngine, opts benchOpts, s benchSetup, // 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. + // corpus, loin de ce que relira la profondeur. Deux ubatchs, mais jamais plus + // du quart du contexte : un preset à -ub 4096 sur 8k de contexte enverrait + // sinon un prompt plus grand que la fenêtre, et le bench échouerait là. 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 { + warmTok := 2 * s.ub + if nCtx > 0 { + warmTok = max(min(warmTok, nCtx/4), 64) + } + warm := warmTok + benchWarmGen + warmText, _ := benchSlice(corp.code, benchLineStart(corp.code, len(corp.code)-int(float64(warmTok)*codeRatio)), len(corp.code)) + if _, err := s.ask(ctx, e, []Message{{Role: "user", Content: warmText + "\n\n" + benchAskFirst}}, benchWarmGen, false); err != nil { return nil, fmt.Errorf("préchauffage : %w", err) } @@ -599,7 +647,7 @@ func benchRun(ctx context.Context, e benchEngine, opts benchOpts, s benchSetup, 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)) + quick, err := s.ask(ctx, e, []Message{{Role: "user", Content: strings.TrimSpace(prompt) + benchAskProse}}, opts.Predict, false) if err != nil { return nil, fmt.Errorf("ligne courte : %w", err) } @@ -621,7 +669,7 @@ func benchRun(ctx context.Context, e benchEngine, opts benchOpts, s benchSetup, res.Elapsed = time.Since(t0).Seconds() return res, nil } - if err := benchDepthRun(ctx, e, s, corp.code, codeRatio, depth, next); err != nil { + if err := benchDepthRun(ctx, e, &s, corp.code, codeRatio, depth, next); err != nil { return nil, err } res.Elapsed = time.Since(t0).Seconds() @@ -635,12 +683,11 @@ func benchTimedOut(ctx context.Context, err error) bool { // 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 { +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)) + cold, err := s.ask(ctx, e, msgs, benchGenTokens, false) if benchTimedOut(ctx, err) { depth.Partial = fmt.Sprintf("prefill à froid interrompu après %s", benchReqTimeout) return nil @@ -651,6 +698,10 @@ func benchDepthRun(ctx context.Context, e benchEngine, s benchSetup, code string ct := cold.turn depth.Cold = &ct prev, answer := cold.promptTotal(), cold.content + // Relectures du disque comptées sur les TOURS seulement (decode à D) : le + // prefill à froid lit légitimement des pages du modèle, il fausserait + // l'indication. + io0, rss0, ioOK := engineIOSample() var newN, gen int var promptMs, genMs float64 for r := 1; r <= benchTurns; r++ { @@ -671,7 +722,7 @@ func benchDepthRun(ctx context.Context, e benchEngine, s benchSetup, code string 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)) + rep, err := s.ask(ctx, e, msgs, benchGenTokens, true) if benchTimedOut(ctx, err) { depth.Partial = fmt.Sprintf("tour %d interrompu après %s", r, benchReqTimeout) break @@ -770,6 +821,10 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben } } setup := benchSetupFrom(cfg, argEnv) + setup.learn = func(want, got string) { + effortRemember(want, got) + logEffortFallback(want, got) + } 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 diff --git a/internal/loki/llm_bench_job.go b/internal/loki/llm_bench_job.go index b22df11..0779fc3 100644 --- a/internal/loki/llm_bench_job.go +++ b/internal/loki/llm_bench_job.go @@ -33,8 +33,10 @@ var errBenchBusy = errors.New("un benchmark occupe le moteur — attends sa fin // 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. +// fonction rendue le libère tant que benching dit que le verrou est encore au +// bench : un Reset l'a peut-être déjà rendu (benching remis à faux), et le tour +// qui a pu démarrer depuis ne doit pas être déclaré libre. L'epoch ne suffit +// pas : changer de discussion le bumpe SANS arrêter le bench (loadFrom). func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) { c.mu.Lock() defer c.mu.Unlock() @@ -42,15 +44,12 @@ func (c *Conversation) benchLease(cancel context.CancelFunc) (func(), error) { 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 + if c.benching { + c.Generating, c.benching, c.cancel = false, false, nil } c.mu.Unlock() }) @@ -115,7 +114,6 @@ func benchStart(opts benchOpts) (id string, code int, err error) { 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 { @@ -123,6 +121,11 @@ func benchStart(opts benchOpts) (id string, code int, err error) { } benchJob.mu.Unlock() }) + canceled := ctx.Err() != nil + // Le verrou du chat est rendu AVANT d'annoncer la fin : l'interface qui + // lit « terminé » et envoie aussitôt un message ne doit pas se voir + // refuser « benchmark en cours ». + release() benchJob.mu.Lock() defer benchJob.mu.Unlock() if benchJob.id != id { @@ -131,9 +134,9 @@ func benchStart(opts benchOpts) (id string, code int, err error) { benchJob.running, benchJob.finished, benchJob.cancel = false, time.Now(), nil benchJob.result = res if err != nil { - benchJob.canceled = ctx.Err() != nil + benchJob.canceled = canceled benchJob.err = err.Error() - if benchJob.canceled { + if canceled { benchJob.err = "benchmark annulé" } } diff --git a/internal/loki/llm_bench_test.go b/internal/loki/llm_bench_test.go index 762eb1f..0fb5348 100644 --- a/internal/loki/llm_bench_test.go +++ b/internal/loki/llm_bench_test.go @@ -18,14 +18,15 @@ import ( // 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 + 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 + rejectEffort string // niveau de reasoning_effort que le « gabarit » refuse (500) + 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 { @@ -57,6 +58,13 @@ func (f *fakeBenchEngine) handler(t *testing.T) http.Handler { http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError) return } + if e, _ := p["reasoning_effort"].(string); f.rejectEffort != "" && e == f.rejectEffort { + // Le vrai message cite la trace jinja d'abord : les niveaux acceptés + // arrivent bien après les 300 premiers caractères. + http.Error(w, `{"error":{"code":500,"message":"`+strings.Repeat("jinja trace ", 40)+ + `Unexpected reasoning effort `+e+`. Supported types are xhigh (default), medium, and low."}}`, http.StatusInternalServerError) + return + } cache, _ := p["cache_prompt"].(bool) if d := f.slowHot; cache && d > 0 { select { @@ -541,3 +549,91 @@ func TestBenchRefuseV1(t *testing.T) { t.Fatalf("pendant le bench : %d, Retry-After=%q", rec.Code, rec.Header().Get("Retry-After")) } } + +// Un gabarit qui refuse le niveau de raisonnement du preset : le bench rejoue +// une fois avec le niveau accepté, comme le chat, et le retient — au lieu +// d'échouer sur le 500. +func TestBenchNiveauRaisonnementRefuse(t *testing.T) { + f := &fakeBenchEngine{nCtx: 16384, rejectEffort: "high"} + eng, _ := f.start(t) + s := benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil) + var learned []string + s.learn = func(want, got string) { learned = append(learned, want+">"+got) } + res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, s, testBenchCorpora(), nil) + if err != nil { + t.Fatalf("refus du niveau non rattrapé : %v", err) + } + if len(f.calls) != 3 || res.PredictedPerSec != 50 { + t.Fatalf("%d requêtes (attendu 3 : refus, préchauffage rejoué, ligne courte)", len(f.calls)) + } + for i, p := range f.calls[1:] { + kw, _ := p["chat_template_kwargs"].(map[string]any) + if p["reasoning_effort"] != "xhigh" || kw["reasoning_effort"] != "xhigh" { + t.Errorf("requête %d : niveau %v / %v, attendu xhigh", i+2, p["reasoning_effort"], kw) + } + } + if len(learned) != 1 || learned[0] != "high>xhigh" { + t.Errorf("traduction retenue : %v", learned) + } + // Une autre erreur que ce refus n'est pas rejouée. + f2 := &fakeBenchEngine{nCtx: 16384, failAt: 1} + eng2, _ := f2.start(t) + if _, err := benchRun(context.Background(), eng2, benchOpts{}, benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil), testBenchCorpora(), nil); err == nil || len(f2.calls) != 1 { + t.Errorf("500 ordinaire : err=%v, %d requêtes", err, len(f2.calls)) + } +} + +// Un gros ubatch sur un petit contexte : le préchauffage reste sous le quart +// de la fenêtre au lieu de la dépasser. +func TestBenchPrechauffageBorne(t *testing.T) { + f := &fakeBenchEngine{nCtx: 8192} + eng, _ := f.start(t) + s := benchSetupFrom(map[string]string{"UBATCH": "4096"}, nil) + if _, err := benchRun(context.Background(), eng, benchOpts{}, s, testBenchCorpora(), nil); err != nil { + t.Fatal(err) + } + c := f.calls[0]["messages"].([]any)[0].(map[string]any)["content"].(string) + if toks := len(c) / 4; toks > 8192/4+64 { + t.Errorf("préchauffage de %d jetons sur 8192 de contexte", toks) + } +} + +// Changer de discussion n'arrête pas le bench et ne rend pas le verrou ; un +// Reset l'arrête et le rend, et la libération tardive du bench ne déclare pas +// libre le tour qui a démarré depuis. +func TestBenchVerrouEtDiscussions(t *testing.T) { + testHome(t) + c := newTestConv() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + release, err := c.benchLease(cancel) + if err != nil { + t.Fatal(err) + } + c.loadFrom("autre", nil) + if ctx.Err() != nil || !c.isGenerating() || c.busyReason() != errBenchBusy { + t.Fatal("changer de discussion a arrêté le bench ou rendu son verrou") + } + release() + if c.isGenerating() { + t.Fatal("verrou non rendu à la fin du bench, après un changement de discussion") + } + + ctx2, cancel2 := context.WithCancel(context.Background()) + defer cancel2() + release2, err := c.benchLease(cancel2) + if err != nil { + t.Fatal(err) + } + c.Reset() + if ctx2.Err() == nil || c.isGenerating() { + t.Fatal("Reset doit arrêter le bench et rendre le verrou") + } + c.mu.Lock() + c.Generating = true // un tour démarré depuis + c.mu.Unlock() + release2() + if !c.isGenerating() { + t.Fatal("la libération tardive du bench a déclaré libre le tour suivant") + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 3ace792..946de83 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -5043,8 +5043,8 @@ function benchResultHTML(x, saved){ 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(['prefill des tours suivants (cache)', (d.cached_per_second||0).toFixed(0)+' tok/s']); + rows.push(['decode en profondeur', (d.decode_per_second||0).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)]); } diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 236b8a0..59ef897 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -75,8 +75,8 @@ function benchResultHTML(x, saved){ 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(['prefill des tours suivants (cache)', (d.cached_per_second||0).toFixed(0)+' tok/s']); + rows.push(['decode en profondeur', (d.decode_per_second||0).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)]); }