Bench : corrections de relecture — niveau de raisonnement, préchauffage borné, verrou fiable

La relecture du bench en tâche de fond a trouvé quatre défauts qui le
faisaient échouer ou s'interrompre là où il n'aurait pas dû, et une
course entre la fin annoncée et le verrou rendu.

- Niveau de raisonnement refusé par le gabarit (Qwen3.8 et « high ») :
  le bench rejoue une fois avec le niveau accepté, comme runChatTools,
  et retient la traduction. Il échouait sur ce 500 dans un processus
  neuf. L'erreur HTTP garde le corps entier : les niveaux acceptés sont
  cités au-delà des 300 caractères affichés.
- Préchauffage borné au quart du contexte : -ub 4096 sur 8k de contexte
  envoyait un prompt plus grand que la fenêtre.
- Changer de discussion n'annule plus le bench, et ne lui reprend pas
  le verrou. La libération suit le drapeau benching au lieu de l'epoch,
  qu'une bascule de fil bumpe. Reset arrête toujours le bench et rend
  le verrou.
- Le verrou du chat est rendu avant que le statut ne dise « terminé » :
  un message envoyé aussitôt n'est plus refusé (et le test n'est plus
  instable).
- Indication « possible thrash » mesurée sur les tours seulement, pas
  sur le prefill à froid qui lit légitimement les pages du modèle.
- Interface : agrégats absents (omitempty) affichés à 0 au lieu de
  casser le rendu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 03:09:01 +02:00
1 parent cf83f5f629
commit 09ee04037b
6 files changed
+206 -42

No files matched your search

+13 -3
View File
@@ -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()
+72 -17
View File
@@ -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
+13 -10
View File
@@ -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é"
}
}
+104 -8
View File
@@ -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")
}
}
+2 -2
View File
@@ -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)]);
}
+2 -2
View File
@@ -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)]);
}