Files
Loki/internal/loki/perf_log.go
T
MichaelandClaude Opus 5.5 539e36ea49 Spéculation : SPEC=ngram et mtp+ngram, n-grammes du contexte en opt-in
En mode Code, le modèle recopie sans cesse ce qui est déjà dans le
contexte : chemins, diffs, arguments JSON, fichiers réécrits. ngram-mod
de llama.cpp y cherche la suite des 24 derniers jetons et propose d'un
coup les 48 à 64 suivants, sans modèle brouillon. La clé SPEC (off par
défaut, inchangé) gagne deux valeurs : ngram, et mtp+ngram qui y ajoute
la tête MTP. Même vérification exacte que MTP : distribution inchangée,
mais pas le même texte au bit près (vérification par lots), donc on
compare des sessions rejouées, pas des diffs.

- forme explicite seulement : --spec-type ngram-mod
  --spec-ngram-mod-n-match 24 --spec-ngram-mod-n-min 48
  --spec-ngram-mod-n-max 64 ; jamais --spec-default (TODO amont, contenu
  susceptible de changer avec le moteur)
- garde-fou d'aide sur « --spec-ngram-mod-n-match » (pas le simple mot
  ngram-mod, encore listé par les moteurs d'avant le renommage) ; absent =
  SPEC ignoré, note au journal
- mtp+ngram : --spec-type draft-mtp,ngram-mod seulement avec draft-mtp
  dans l'aide ET une tête MTP réelle (tenseur nextn ou MODEL_DRAFT) ;
  sinon n-grammes seuls, avec la raison — jamais « failed to create MTP
  context » en boucle
- EXTRA_ARGS/LLAMA_ARG_* : mêmes exclusions qu'avant, plus --spec_type et
  tout --spec-ngram-* (--spec-type s'additionne sans prévenir) ; pour
  ngram seul, un --spec-draft-* fait aussi taire Loki
- poids sur CPU (experts MoE, -ot, NGL partiel) : refusé tant que
  GGML_OP_OFFLOAD_MIN_BATCH (env ou OP_OFFLOAD_MIN_BATCH) ne dépasse pas
  le lot de vérification de 65 jetons, avec la suggestion
  OP_OFFLOAD_MIN_BATCH=128 ; avertissements pour un modèle plus gros que
  la RAM, les points de reprise d'un hybride, NGL imposé (logits)
- avec ngram, une tête MTP ou MODEL_DRAFT inutilisés sont signalés
- --spec-synth-len/--spec-synth-rates (et LLAMA_ARG_SPEC_SYNTH_LEN) :
  acceptation au hasard, avertissement au lancement ; Loki ne les pose
  jamais
- télémétrie : /api/perf/summary donne draft_n, draft_accepted et
  draft_rate par nature de requête
- UI : deux options du sélecteur de décodage spéculatif ; README et
  configTemplate
- tests : forme explicite, garde d'aide, repli de mtp+ngram, exclusions,
  seuil d'offload, ligne inchangée sans SPEC, acceptation par nature

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:28:25 +02:00

605 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"math"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Télémétrie par complétion : combien du prompt venait du cache, combien a été
// recalculé, à quelle vitesse, et ce que le décodage spéculatif a gardé.
//
// Rien de tout ça ne retourne au moteur : aucun champ ajouté aux requêtes, le
// comptage du contexte (usage.prompt_tokens → CtxUsed → compaction) reste
// celui d'avant. On LIT ce que llama.cpp envoie déjà sur son chunk final, on le
// garde en mémoire (anneau borné, aucun texte de message) et on l'expose en
// agrégats sur /api/perf/summary. Sans ces chiffres, impossible de savoir si une
// ligne volatile s'est glissée dans le prompt, si une mise à jour du moteur a
// cassé les points de reprise, ou si l'acceptation du MTP s'est effondrée.
// Nature d'une complétion. Elle voyage dans le contexte (withPerf) et jamais
// dans Caps : Caps est une liste de capacités, recopiée par valeur partout.
const (
perfMain = "main" // tour de la conversation (et ses étapes d'outils)
perfSubagent = "subagent" // sous-agent du mode Code
perfVerify = "verify" // passe de vérification du mode Code
perfTask = "task" // tâche planifiée
perfCompact = "compact" // résumé de compaction
perfBench = "bench" // mesure du moteur (cache_prompt:false)
perfForeign = "foreign" // client externe passé par /v1 : vu, pas mesuré
perfPrewarm = "prewarm" // préchauffage du cache (PREWARM) : réponse jetée
)
type perfCtxKey struct{}
type perfTag struct{ kind, conv string }
// withPerf étiquette les complétions lancées sous ctx.
func withPerf(ctx context.Context, kind, conv string) context.Context {
return context.WithValue(ctx, perfCtxKey{}, perfTag{kind: kind, conv: conv})
}
// withPerfKind change la nature en gardant la conversation : un sous-agent ou
// une vérification reste rattaché à la discussion qui l'a lancé.
func withPerfKind(ctx context.Context, kind string) context.Context {
t := perfTagOf(ctx)
t.kind = kind
return context.WithValue(ctx, perfCtxKey{}, t)
}
func perfTagOf(ctx context.Context) perfTag {
t, _ := ctx.Value(perfCtxKey{}).(perfTag)
if t.kind == "" {
t.kind = perfMain
}
return t
}
// perfNum : un nombre de télémétrie, décodé sans JAMAIS échouer. Un fork ou une
// API tierce qui l'envoie en flottant, en chaîne ou pas du tout le laisse
// simplement « inconnu » : une valeur d'affichage ne doit pas pouvoir jeter le
// chunk final (texte, finish_reason, usage) ni faire échouer une compaction.
type perfNum struct {
f float64
ok bool
}
func (n *perfNum) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
if f, err := strconv.ParseFloat(s, 64); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) && f >= 0 && f < 1e12 {
*n = perfNum{f: f, ok: true}
}
return nil
}
func (n perfNum) int() int { return int(n.f) }
func (n perfNum) ptr() *int {
if !n.ok {
return nil
}
v := int(n.f)
return &v
}
// perfWire : les champs de télémétrie du chunk final, lus dans un DEUXIÈME
// décodage, séparé de streamChunk et dont l'erreur est ignorée. Les compteurs
// cache_n/draft_n n'existent pas sur tous les moteurs (draft_n n'apparaît
// qu'avec un brouillon actif) : absents, ils restent inconnus, jamais 0.
type perfWire struct {
Timings *struct {
PromptN perfNum `json:"prompt_n"`
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"`
DraftNAccepted perfNum `json:"draft_n_accepted"`
} `json:"timings"`
Usage *struct {
PromptTokens perfNum `json:"prompt_tokens"`
CompletionTokens perfNum `json:"completion_tokens"`
PromptTokensDetails *struct {
CachedTokens perfNum `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
} `json:"usage"`
}
func decodePerfWire(data []byte) perfWire {
// Decoder plutôt qu'Unmarshal : un octet parasite après la réponse ne doit
// pas tout rendre inconnu. Ce qui a pu être lu l'est ; le reste reste inconnu.
var w perfWire
_ = json.NewDecoder(bytes.NewReader(data)).Decode(&w)
return w
}
// cachedTokens : usage.prompt_tokens_details.cached_tokens (format OpenAI).
func (w perfWire) cachedTokens() perfNum {
if w.Usage == nil || w.Usage.PromptTokensDetails == nil {
return perfNum{}
}
return w.Usage.PromptTokensDetails.CachedTokens
}
// applyPerf reporte la télémétrie d'un chunk sur les stats. timings.cache_n a
// la priorité ; cached_tokens ne fait que combler un cache encore inconnu (les
// deux viennent du même compteur côté llama.cpp, mais seul le second existe
// sur une API tierce).
func (s *StatsEvent) applyPerf(w perfWire) {
if t := w.Timings; t != nil {
if p := t.CacheN.ptr(); p != nil {
s.CacheTokens = p
}
if p := t.DraftN.ptr(); p != nil {
s.DraftN = p
}
if p := t.DraftNAccepted.ptr(); p != nil {
s.DraftAccepted = p
}
}
if s.CacheTokens == nil {
s.CacheTokens = w.cachedTokens().ptr()
}
}
// perfRec : une complétion. Des identifiants et des compteurs, jamais de texte.
type perfRec struct {
At time.Time `json:"at"`
Seq uint64 `json:"seq"`
Kind string `json:"kind"`
Conv string `json:"conv,omitempty"`
Iter int `json:"iter"`
Complete bool `json:"complete"`
// Total : usage.prompt_tokens, la taille du prompt entier. New : prompt_n,
// les jetons réellement recalculés. Cached : cache_n, absent si inconnu.
Total int `json:"total,omitempty"`
Cached *int `json:"cached,omitempty"`
New int `json:"new,omitempty"`
PPms float64 `json:"pp_ms,omitempty"`
PPtps float64 `json:"pp_tps,omitempty"`
Gen int `json:"gen,omitempty"`
TGtps float64 `json:"tg_tps,omitempty"`
DraftN *int `json:"draft_n,omitempty"`
DraftAcc *int `json:"draft_accepted,omitempty"`
TTFTms *int64 `json:"ttft_ms,omitempty"`
Lost *int `json:"lost,omitempty"`
LostAfter string `json:"lost_after,omitempty"`
}
// perfRecFromStats : la complétion telle que runChat l'a vue.
func perfRecFromStats(t perfTag, iter int, complete bool, s StatsEvent) perfRec {
return perfRec{
Kind: t.kind, Conv: t.conv, Iter: iter, Complete: complete,
Total: s.PromptTokensTotal, Cached: s.CacheTokens, New: s.PromptTokens,
PPms: s.PromptMs, PPtps: s.PromptPerSecond, Gen: s.GenTokens, TGtps: s.GenPerSecond,
DraftN: s.DraftN, DraftAcc: s.DraftAccepted, TTFTms: s.TTFTms,
}
}
// perfRecFromWire : une réponse NON streamée (compaction), lue d'un bloc.
func perfRecFromWire(t perfTag, w perfWire) perfRec {
r := perfRec{Kind: t.kind, Conv: t.conv}
if tm := w.Timings; tm != nil {
r.New, r.PPms, r.PPtps = tm.PromptN.int(), tm.PromptMs.f, tm.PromptPerSecond.f
r.Gen, r.TGtps = tm.PredictedN.int(), tm.PredictedPerSec.f
r.Cached, r.DraftN, r.DraftAcc = tm.CacheN.ptr(), tm.DraftN.ptr(), tm.DraftNAccepted.ptr()
}
if u := w.Usage; u != nil {
r.Total = u.PromptTokens.int()
if r.Gen == 0 {
r.Gen = u.CompletionTokens.int()
}
}
if r.Cached == nil {
r.Cached = w.cachedTokens().ptr()
}
r.Complete = r.Total > 0 || w.Timings != nil
return r
}
// perfPrefix : empreintes cumulées des messages envoyés (l'empreinte i couvre
// les messages 0..i). C'est ce qui dit qu'une requête PROLONGE la précédente
// de la même conversation — l'identifiant seul ne suffit pas : une compaction,
// une édition ou une autre tâche dans la même discussion repartent d'ailleurs.
func perfPrefix(msgs []Message) []uint64 {
h := fnv.New64a()
out := make([]uint64, len(msgs))
for i, m := range msgs {
b, _ := json.Marshal(m)
h.Write(b)
h.Write([]byte{0})
out[i] = h.Sum64()
}
return out
}
const (
perfRingMax = 5000 // ~1 Mio en mémoire au pire
perfConvMax = 128 // conversations suivies pour le calcul de la perte
perfBetween = 500 // entrées remontées au plus pour nommer ce qui s'est intercalé
)
// perfConvState : la dernière complétion terminée d'une conversation (et d'une
// nature : le fil d'un sous-agent n'est pas celui de son builder).
type perfConvState struct {
seq uint64
n int
hash uint64
total int
}
type perfStore struct {
mu sync.Mutex
seq uint64
ring []perfRec
head int // prochaine case à écraser quand l'anneau est plein
convs map[string]perfConvState
}
var perfLog = &perfStore{}
// record range la complétion et calcule sa perte de cache :
//
// lost = max(0, total précédent − cache_n)
//
// seulement quand le cache est connu, que la complétion est allée au bout, et
// que ses messages PROLONGENT strictement ceux de la précédente complétion de
// la même conversation et de la même nature. Ailleurs (nouvelle discussion,
// compaction, flux coupé) la perte reste inconnue plutôt que fausse.
func (p *perfStore) record(r perfRec, prefix []uint64) perfRec {
p.mu.Lock()
defer p.mu.Unlock()
p.seq++
r.Seq = p.seq
if r.At.IsZero() {
r.At = time.Now()
}
key := r.Kind + "\x00" + r.Conv
if p.convs == nil {
p.convs = map[string]perfConvState{}
}
if r.Complete && len(prefix) > 0 {
prev, had := p.convs[key]
if had && r.Cached != nil && prev.total > 0 && prev.n > 0 && len(prefix) > prev.n && prefix[prev.n-1] == prev.hash {
lost := max(0, prev.total-*r.Cached)
r.Lost = &lost
if lost > 0 {
r.LostAfter = p.between(prev.seq, key)
}
}
if r.Total > 0 {
p.convs[key] = perfConvState{seq: r.Seq, n: len(prefix), hash: prefix[len(prefix)-1], total: r.Total}
p.evictConvs()
}
}
if len(p.ring) < perfRingMax {
p.ring = append(p.ring, r)
} else {
p.ring[p.head] = r
p.head = (p.head + 1) % perfRingMax
}
return r
}
// between nomme les natures des complétions passées par le moteur depuis seq
// (un sous-agent, un client externe…) : la cause probable d'une perte. Vide =
// rien vu d'autre, perte non attribuée.
func (p *perfStore) between(seq uint64, key string) string {
seen := map[string]bool{}
n := len(p.ring)
// De la plus récente à la plus ancienne. Tant que l'anneau n'est pas plein,
// head vaut 0 et la plus récente est la dernière case : même formule.
for i := 0; i < n && i < perfBetween; i++ {
e := p.ring[((p.head-1-i)%n+n)%n]
if e.Seq <= seq {
break
}
if e.Kind+"\x00"+e.Conv != key {
seen[e.Kind] = true
}
}
kinds := make([]string, 0, len(seen))
for k := range seen {
kinds = append(kinds, k)
}
sort.Strings(kinds)
return strings.Join(kinds, ",")
}
// evictConvs borne l'état par conversation : on oublie la plus ancienne.
func (p *perfStore) evictConvs() {
for len(p.convs) > perfConvMax {
oldK, oldS := "", uint64(math.MaxUint64)
for k, s := range p.convs {
if s.seq < oldS {
oldK, oldS = k, s.seq
}
}
delete(p.convs, oldK)
}
}
// snapshot : copie de l'anneau, de la plus ancienne à la plus récente.
func (p *perfStore) snapshot() []perfRec {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]perfRec, 0, len(p.ring))
out = append(out, p.ring[p.head:]...)
return append(out, p.ring[:p.head]...)
}
// perfRecord range une complétion et, si LOKI_PERF_LOG est posée, l'écrit sur
// stderr. Une ligne par complétion, c'est des dizaines par tour d'agent : pas
// par défaut, les journaux Docker ont déjà celles de llama-server.
func perfRecord(r perfRec, prefix []uint64) perfRec {
r = perfLog.record(r, prefix)
if perfLogOn() {
fmt.Fprintln(os.Stderr, perfLine(r))
}
return r
}
// perfNoteForeign : une requête d'un client externe vient de passer par /v1.
// Ses chiffres ne sont pas lus (le flux est relayé tel quel), mais sa trace
// suffit à expliquer la perte de cache du tour suivant.
func perfNoteForeign() {
perfRecord(perfRec{Kind: perfForeign}, nil)
}
func perfLogOn() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("LOKI_PERF_LOG")))
return v != "" && v != "0" && v != "false" && v != "off" && v != "no"
}
func perfLine(r perfRec) string {
opt := func(p *int) string {
if p == nil {
return "?"
}
return strconv.Itoa(*p)
}
draft := "?"
if r.DraftN != nil {
draft = opt(r.DraftAcc) + "/" + strconv.Itoa(*r.DraftN)
}
ttft := "?"
if r.TTFTms != nil {
ttft = strconv.FormatInt(*r.TTFTms, 10)
}
lost := opt(r.Lost)
if r.LostAfter != "" {
lost += "(" + r.LostAfter + ")"
}
conv := r.Conv
if conv == "" {
conv = "-"
}
return fmt.Sprintf("[perf] kind=%s conv=%s iter=%d total=%d cached=%s new=%d pp_ms=%.0f pp_tps=%.1f gen=%d tg_tps=%.1f draft=%s ttft_ms=%s lost=%s complete=%t",
r.Kind, conv, r.Iter, r.Total, opt(r.Cached), r.New, r.PPms, r.PPtps, r.Gen, r.TGtps, draft, ttft, lost, r.Complete)
}
// perfLostAlertAt : la perte au-delà de laquelle l'interface la signale. Sur un
// modèle hybride (couches récurrentes), le cache ne reprend qu'à un point de
// reprise : perdre jusqu'à un espacement de points (--checkpoint-min-step,
// 2048 posé par Loki) plus un micro-batch est l'état NORMAL, pas un incident.
// Le signaler à chaque étape noierait les vraies pertes.
//
// Même ordre de priorité que le lancement (ckptArgs) : un -cms d'EXTRA_ARGS ou
// LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT l'emporte sur CKPT_MIN_STEP, un -ub
// d'EXTRA_ARGS sur UBATCH.
func perfLostAlertAt(cfg map[string]string) int {
const base = 1000
extra := splitArgs(cfg["EXTRA_ARGS"])
num := func(vals ...string) int {
for _, v := range vals {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n
}
}
return 0
}
step := num(flagValue(extra, "-cms", "--checkpoint-min-step"),
os.Getenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"), cfg["CKPT_MIN_STEP"])
if m := strings.TrimSpace(cfg["MODEL"]); step == 0 && m != "" {
if p, err := resolveServeModelPath(m); err == nil {
if g, err := ggufMeta(p); err == nil && ggufHybrid(&g) {
step = 2048
}
}
}
if step == 0 {
return base
}
ub := num(flagValue(extra, "-ub", "--ubatch-size"), cfg["UBATCH"])
if ub == 0 {
ub = 512
}
return max(base, step+ub)
}
// perfAlert : la complétion mérite d'être signalée — une perte au-delà du seuil,
// ou un gros prompt repris de zéro alors que le cache est connu.
func perfAlert(r perfRec, threshold int) bool {
if r.Lost != nil && *r.Lost > threshold {
return true
}
return r.Cached != nil && *r.Cached == 0 && r.Total >= 8*threshold
}
// --- /api/perf/summary -------------------------------------------------------
type perfKindSum struct {
Completions int `json:"completions"`
Incomplete int `json:"incomplete"`
// Turns : premières complétions (iter 0) — un tour utilisateur, un
// sous-agent, une passe de vérification…
Turns int `json:"turns"`
NewTokens int `json:"new_tokens"`
PrefillSec float64 `json:"prefill_sec"`
NewTokensPerTurn float64 `json:"new_tokens_per_turn,omitempty"`
PrefillSecPerTurn float64 `json:"prefill_sec_per_turn,omitempty"`
LostEvents int `json:"lost_events"`
LostTokens int `json:"lost_tokens"`
TTFTMedianMs *float64 `json:"ttft_median_ms,omitempty"`
// Acceptation du brouillon par nature : un n-gramme paie en mode code
// (chemins, diffs recopiés) et presque pas en prose — le taux global
// mélangerait les deux. Absent = moteur muet ou spéculation coupée.
DraftN int `json:"draft_n,omitempty"`
DraftAccepted int `json:"draft_accepted,omitempty"`
DraftRate *float64 `json:"draft_rate,omitempty"`
}
type perfDepthSum struct {
Bucket string `json:"bucket"`
N int `json:"n"`
PPMedianTPS *float64 `json:"pp_median_tps,omitempty"`
TGMedianTPS *float64 `json:"tg_median_tps,omitempty"`
}
type perfSummary struct {
Entries int `json:"entries"`
Since time.Time `json:"since,omitzero"`
// CacheHitRatio : Σ cached / Σ total sur les complétions où les deux sont
// connus. Absent = moteur qui ne dit rien de son cache.
CacheHitRatio *float64 `json:"cache_hit_ratio,omitempty"`
Kinds map[string]*perfKindSum `json:"kinds"`
Depth []perfDepthSum `json:"depth"`
DraftN int `json:"draft_n"`
DraftAccepted int `json:"draft_accepted"`
DraftRate *float64 `json:"draft_rate,omitempty"`
// Template : dernier verdict de la sonde de gabarit (chat_tplprobe.go),
// absent tant qu'aucune n'a tourné ou sur un preset externe.
Template *tplProbeResult `json:"template,omitempty"`
}
var perfDepthBuckets = []struct {
name string
max int
}{{"0-8k", 8 << 10}, {"8-16k", 16 << 10}, {"16-32k", 32 << 10}, {"32k+", math.MaxInt}}
func perfMedian(v []float64) *float64 {
if len(v) == 0 {
return nil
}
sort.Float64s(v)
m := v[len(v)/2]
if len(v)%2 == 0 {
m = (v[len(v)/2-1] + v[len(v)/2]) / 2
}
return &m
}
func perfSummarize(recs []perfRec) perfSummary {
s := perfSummary{Entries: len(recs), Kinds: map[string]*perfKindSum{}}
if len(recs) > 0 {
s.Since = recs[0].At
}
var sumCached, sumTotal int
pp := make([][]float64, len(perfDepthBuckets))
tg := make([][]float64, len(perfDepthBuckets))
ttft := map[string][]float64{}
for _, r := range recs {
k := s.Kinds[r.Kind]
if k == nil {
k = &perfKindSum{}
s.Kinds[r.Kind] = k
}
if !r.Complete {
k.Incomplete++
continue
}
k.Completions++
if r.Iter == 0 {
k.Turns++
}
k.NewTokens += r.New
k.PrefillSec += r.PPms / 1000
if r.Lost != nil && *r.Lost > 0 {
k.LostEvents++
k.LostTokens += *r.Lost
}
if r.TTFTms != nil {
ttft[r.Kind] = append(ttft[r.Kind], float64(*r.TTFTms))
}
if r.Cached != nil && r.Total > 0 {
sumCached += min(*r.Cached, r.Total)
sumTotal += r.Total
}
depth := r.Total
if depth == 0 {
depth = r.New
}
for i, b := range perfDepthBuckets {
if depth < b.max {
// Débit de lecture : seulement sur un vrai prefill (quelques jetons
// nouveaux donnent des débits sans signification).
if r.PPtps > 0 && r.New >= 64 {
pp[i] = append(pp[i], r.PPtps)
}
if r.TGtps > 0 && r.Gen > 1 {
tg[i] = append(tg[i], r.TGtps)
}
break
}
}
if r.DraftN != nil {
s.DraftN += *r.DraftN
k.DraftN += *r.DraftN
if r.DraftAcc != nil {
s.DraftAccepted += *r.DraftAcc
k.DraftAccepted += *r.DraftAcc
}
}
}
for kind, k := range s.Kinds {
if k.Turns > 0 {
k.NewTokensPerTurn = float64(k.NewTokens) / float64(k.Turns)
k.PrefillSecPerTurn = k.PrefillSec / float64(k.Turns)
}
k.TTFTMedianMs = perfMedian(ttft[kind])
if k.DraftN > 0 {
r := float64(k.DraftAccepted) / float64(k.DraftN)
k.DraftRate = &r
}
}
if sumTotal > 0 {
r := float64(sumCached) / float64(sumTotal)
s.CacheHitRatio = &r
}
if s.DraftN > 0 {
r := float64(s.DraftAccepted) / float64(s.DraftN)
s.DraftRate = &r
}
for i, b := range perfDepthBuckets {
s.Depth = append(s.Depth, perfDepthSum{Bucket: b.name, N: max(len(pp[i]), len(tg[i])),
PPMedianTPS: perfMedian(pp[i]), TGMedianTPS: perfMedian(tg[i])})
}
return s
}
// handlePerfSummary : GET /api/perf/summary (derrière la clé de pilotage).
// Des agrégats seulement : aucun texte de message n'est jamais retenu.
func handlePerfSummary(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET seulement"})
return
}
s := perfSummarize(perfLog.snapshot())
if r, ok := tplCapsCurrent(); ok {
s.Template = &r
}
sendJSON(w, http.StatusOK, s)
}