Files
llmgw/internal/cli/bench.go
T
Ilker ManapandClaude Opus 5 3aa250428a Keep what a benchmark found out, instead of leaving it in a terminal
`ctl bench` has always been able to work out what one replica can serve — it
ramps concurrency until the SLO breaks and reports the last level that held.
It printed that and nothing kept it, so the one number saying what a replica is
worth lived in whoever ran it's scrollback. Everything downstream then had to
guess: admission applies a single global default for healthy queue depth, and
sizing a model against real demand had nothing to size against.

`ctl bench --save` records it; `ctl capacity` reads it back.

Keyed by model and profile. Capacity belongs to the hardware and the engine
arguments as much as to the weights, so the same model on CPU and on an H100
are two numbers, and one key would overwrite the other on every run.

The profile is looked up from the replica rather than asked for, because the
control plane already knows which profile answers on which endpoint and a
measurement filed under the wrong one is worse than none. A target that is not
a replica — the gateway, usually — is refused with that said: benchmarking the
whole path is worth doing and says nothing about the hardware.

The conclusion is stored, not the ramp. Every level and its latencies are worth
reading once while judging whether the run was sound; keeping them would grow
without bound as a model is re-benchmarked.

Re-measuring replaces. A second run is a better answer to the same question,
not another data point, and keeping both would leave a reader choosing between
numbers with no basis for the choice.

The SLO and the date are stored with it. Capacity means nothing without the
target it was measured against, and hardware and engine builds change — a
measurement is an observation with a date on it, not a fact about the model.

A failed save is reported and not fatal: the run has already cost the GPU time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:51:58 +02:00

230 lines
8.6 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func newBenchCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
cfg := bench.DefaultConfig()
var (
steps []int
target string
metrics string
engine string
key string
save bool
)
cmd := &cobra.Command{
Use: "bench <model>",
Short: "Measure what a replica can do, and what the router should be told",
Long: "Drives real traffic. Point it at a replica to measure that hardware, or at\n" +
"the gateway to measure the whole path — they answer different questions,\n" +
"and only the first produces a capacity profile.\n\n" +
"Concurrency climbs until the SLO breaks. The last level that still met it\n" +
"is the capacity: peak throughput arrives long after latency has become\n" +
"unusable, so throughput alone would overstate what the hardware can serve.\n\n" +
"This costs real GPU time and competes with real traffic. Run it on a\n" +
"replica you have cordoned, or during a window where that is acceptable.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg.Model = args[0]
cfg.Target = target
cfg.MetricsURL = metrics
cfg.Engine = engine
cfg.APIKey = key
if len(steps) > 0 {
cfg.Concurrency = steps
}
if metrics == "" {
cmd.PrintErrln("No --metrics endpoint: queue depth and KV usage will not be " +
"measured, so target_waiting and kv_ceiling cannot be suggested.")
}
cmd.PrintErrf("benchmarking %s against %s\n\n", cfg.Model, cfg.Target)
// Fixed columns rather than a tabwriter: rows are printed as each
// level finishes, and a tabwriter cannot align a column it has not
// seen the rest of yet — it lays out whatever has been flushed, so
// every row after the first came out ragged.
const row = "%-5s %-7s %-7s %-9s %-9s %-9s %-6s %-5s %s\n"
fmt.Fprintf(cmd.ErrOrStderr(), row,
"CONC", "REQ/S", "TOK/S", "TTFT p50", "TTFT p99", "TPOT p99", "WAIT", "KV", "SLO")
progress := func(s bench.Step) {
verdict := "ok"
if !s.MeetsSLO {
verdict = "MISSED"
}
if s.Failed > 0 {
verdict = fmt.Sprintf("%d failed", s.Failed)
}
fmt.Fprintf(cmd.ErrOrStderr(), row,
fmt.Sprint(s.Concurrency),
fmt.Sprintf("%.1f", s.RequestsPerSec),
fmt.Sprintf("%.0f", s.TokensPerSec),
ms(s.TTFT.P50), ms(s.TTFT.P99), ms(s.TPOT.P99),
fmt.Sprintf("%.1f", s.Waiting),
fmt.Sprintf("%.0f%%", s.KVUsage*100),
verdict)
}
res, err := bench.Run(cmd.Context(), cfg, progress)
if err != nil {
return err
}
if save {
if err := saveCapacity(cmd, client, res, target); err != nil {
// Reported, not fatal. The run has already cost the GPU
// time; losing its conclusion to a failed write would mean
// running it again, and the numbers are on screen either
// way.
cmd.PrintErrf("the measurement was not saved: %v\n", err)
}
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(res)
}
printConclusion(cmd, res, cfg)
return nil
},
}
f := cmd.Flags()
f.StringVar(&target, "target", "http://127.0.0.1:8080", "base URL: a replica, or the gateway")
f.StringVar(&metrics, "metrics", "", "the replica's /metrics, to observe queue depth and KV usage")
f.StringVar(&engine, "engine", "", "which metric names --metrics uses: vllm | sglang | llamacpp")
f.StringVar(&key, "key", "", "API key, when the gateway requires one")
f.IntSliceVar(&steps, "concurrency", nil, "levels to try (default 1,2,4,…,128)")
f.DurationVar(&cfg.Duration, "duration", cfg.Duration, "how long to hold each level")
f.IntVar(&cfg.PromptTokens, "prompt-tokens", cfg.PromptTokens, "approximate prompt size")
f.IntVar(&cfg.MaxTokens, "max-tokens", cfg.MaxTokens, "tokens to generate per request")
f.BoolVar(&cfg.SharedPrefix, "shared-prefix", false,
"send every request the same prompt, measuring the cached case instead of the hardware")
f.DurationVar(&cfg.SLO.TTFTp99, "ttft-p99", cfg.SLO.TTFTp99, "time-to-first-token target")
f.DurationVar(&cfg.SLO.TPOTp99, "tpot-p99", cfg.SLO.TPOTp99, "per-output-token target")
f.BoolVar(&save, "save", false,
"record the capacity against the model and profile, so the number outlives this terminal")
return cmd
}
// saveCapacity records a run's conclusion against the profile it was measured
// on.
//
// The profile is looked up from the replica rather than asked for. Capacity
// belongs to the hardware and the engine arguments as much as to the weights,
// so a measurement filed under the wrong profile is worse than none — and the
// control plane already knows which profile is answering on which endpoint, so
// nobody needs to be trusted to type it.
func saveCapacity(cmd *cobra.Command, client func() *ctlClient, res bench.Result, target string) error {
var reps []adminapi.ReplicaView
if err := client().get(cmd.Context(), "/api/v1/replicas", &reps); err != nil {
return err
}
profile := ""
for _, rep := range reps {
if rep.Model == res.Model && sameEndpoint(rep.Endpoint, target) {
profile = rep.Profile
break
}
}
if profile == "" {
// Almost always because --target was the gateway. Benchmarking the
// whole path is a useful thing to do and says nothing about what one
// replica can serve, so there is nothing here worth filing as capacity.
return fmt.Errorf("%s is not a replica of %s, so this run measures the path rather than the hardware; "+
"point --target at a replica endpoint from `llmgw ctl replicas`", target, res.Model)
}
body, err := json.Marshal(res.Capacity(profile))
if err != nil {
return err
}
var saved bench.Capacity
if err := client().put(cmd.Context(),
"/api/v1/models/"+res.Model+"/capacity", body, &saved); err != nil {
return err
}
cmd.PrintErrf("saved: %s on profile %s, %d concurrent at the SLO\n",
saved.Model, saved.Profile, saved.MaxConcurrencyAtSLO)
return nil
}
// sameEndpoint compares two base URLs, ignoring a trailing slash.
//
// Written out because the two come from different places: one is what the
// control plane recorded, the other is what somebody typed or pasted, and a
// trailing slash is the difference that would silently make every run look like
// it was aimed at the gateway.
func sameEndpoint(a, b string) bool {
return strings.TrimSuffix(a, "/") == strings.TrimSuffix(b, "/")
}
func printConclusion(cmd *cobra.Command, res bench.Result, cfg bench.Config) {
out := cmd.OutOrStdout()
fmt.Fprintln(out)
if res.MaxConcurrencyAtSLO == 0 {
fmt.Fprintf(out, "No level met the SLO (TTFT p99 ≤ %s, TPOT p99 ≤ %s).\n",
cfg.SLO.TTFTp99, cfg.SLO.TPOTp99)
if len(res.Steps) > 0 && res.Steps[0].FirstError != "" {
fmt.Fprintf(out, "First error: %s\n", res.Steps[0].FirstError)
}
fmt.Fprintln(out, "Either the hardware cannot serve this model at this SLO, or the")
fmt.Fprintln(out, "target is stricter than the model can meet at any concurrency.")
return
}
fmt.Fprintf(out, "Capacity: %d concurrent requests at %.0f tokens/s\n",
res.MaxConcurrencyAtSLO, res.ThroughputTokPerSec)
fmt.Fprintf(out, " TTFT p99 %s · TPOT p99 %s · measured over %s\n\n",
ms(res.TTFTp99), ms(res.TPOTp99), res.Elapsed.Round(time.Second))
fmt.Fprintln(out, "Put this in the model's profile:")
fmt.Fprintf(out, " weight: %d\n", res.MaxConcurrencyAtSLO)
if res.SuggestedTargetWaiting > 0 || res.SuggestedKVCeiling > 0 {
fmt.Fprintln(out, "\nAnd in router.scoring, from what the engine reported while still")
fmt.Fprintln(out, "meeting the target:")
if res.SuggestedTargetWaiting > 0 {
fmt.Fprintf(out, " target_waiting: %d\n", res.SuggestedTargetWaiting)
}
if res.SuggestedKVCeiling > 0 {
fmt.Fprintf(out, " kv_ceiling: %.2f\n", res.SuggestedKVCeiling)
}
}
if last := res.Steps[len(res.Steps)-1]; !last.MeetsSLO {
fmt.Fprintf(out, "\n%d concurrent was the first level to miss it (TTFT p99 %s).\n",
last.Concurrency, ms(last.TTFT.P99))
} else {
fmt.Fprintf(out, "\nEvery level tried met the SLO, so %d is a floor rather than a\n",
res.MaxConcurrencyAtSLO)
fmt.Fprintln(out, "ceiling — raise --concurrency to find where it actually breaks.")
}
if cfg.SharedPrefix {
fmt.Fprintln(out, "\nRun with --shared-prefix: this measures the engine's prefix cache,")
fmt.Fprintln(out, "not its hardware. Do not use these numbers as capacity.")
}
}
func ms(d time.Duration) string {
switch {
case d == 0:
return "-"
case d < time.Millisecond:
return d.Round(10 * time.Microsecond).String()
case d < time.Second:
return d.Round(time.Millisecond).String()
default:
return d.Round(10 * time.Millisecond).String()
}
}