Files
llmgw/internal/bench/bench.go
T
Ilker ManapandClaude Opus 5.5 9853936e0b Give every engine a key only the router holds
Engines listen on every address their node has, with no key. Checked on the
live cluster: from a neighbouring node, a request straight to an engine's
port was answered — past the gateway's authentication, quotas and
accounting, by anyone who could reach the node.

Each replica's engine is now started with a key and refuses requests without
it. The key is derived from the replica id with an HMAC over a secret kept
in the store, rather than stored per replica: the router has to send the
right key to replicas this process never started — adopted after a restart,
or placed by the other control plane — and a function of a shared secret
gives that without a table to keep in step. A key taken from one node opens
that replica only. The secret is made on first use and copied with the rest
of the store when moving to PostgreSQL; a new one would lock the router out
of every running engine at the moment of the move.

The key reaches llama.cpp and vLLM through their environment
(LLAMA_API_KEY, VLLM_API_KEY) rather than their command line, which every
user on the node can read; SGLang only takes it as an argument. The agent's
readiness check, the router's proxy and its metrics scrape send it, the
client's own Authorization still never reaches the engine, and `ctl bench`
fetches the key of the replica it is pointed at from the admin API.

Engines started before this keep running without a key until their replica
is next started. With no store there is nowhere to keep the secret, and
engines start without one, with a warning.

Also formats two files that went in unformatted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 00:38:29 +02:00

439 lines
12 KiB
Go

// Package bench measures what a replica can actually do, so the numbers the
// router depends on stop being guesses (plan §6.2.1).
//
// Three of them matter: the weight a replica gets when the scheduler compares
// it to others, the queue depth admission treats as healthy, and the KV usage
// it treats as full. All three are currently hand-written in configuration, and
// a hand-written capacity number is a hand-written opinion about hardware
// nobody measured.
//
// The measurement is a load ramp. Concurrency climbs until the SLO breaks, and
// the last level that still met it is the capacity — not peak throughput, which
// a GPU will happily deliver long after latency has become unusable.
package bench
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand/v2"
"net/http"
"sort"
"strings"
"sync"
"time"
)
// SLO is the latency target capacity is measured against.
type SLO struct {
TTFTp99 time.Duration
TPOTp99 time.Duration
}
// Met reports whether a step's latencies satisfy the target.
func (s SLO) Met(ttft, tpot time.Duration) bool {
if s.TTFTp99 > 0 && ttft > s.TTFTp99 {
return false
}
if s.TPOTp99 > 0 && tpot > s.TPOTp99 {
return false
}
return true
}
// Config describes a run.
type Config struct {
// Target is the base URL: the gateway, or one replica directly. Pointing at
// a replica measures that replica; pointing at the gateway measures the
// whole path including scheduling, which is a different question.
Target string
// MetricsURL is an engine's /metrics, sampled during the run so queue depth
// and KV usage can be reported alongside latency. Only meaningful when
// Target is a single replica.
MetricsURL string
// Engine names which metric vocabulary MetricsURL speaks. Getting it wrong
// is silent: nothing matches, every sample reads zero, and the suggested
// thresholds are built on nothing.
Engine string
Model string
APIKey string
// Concurrency is the ramp. Each level runs for Duration.
Concurrency []int
Duration time.Duration
PromptTokens int
MaxTokens int
// SharedPrefix sends every request the same prompt.
//
// Off by default, and that default is the whole point: with a shared prefix
// the engine's cache answers most of the prefill and the measurement
// describes the cache rather than the hardware. Turning it on measures the
// cached case deliberately, which is a fair question to ask separately.
SharedPrefix bool
SLO SLO
}
// DefaultConfig returns a ramp that covers most single-replica hardware.
func DefaultConfig() Config {
return Config{
Concurrency: []int{1, 2, 4, 8, 16, 32, 64, 128},
Duration: 20 * time.Second,
PromptTokens: 512,
MaxTokens: 128,
SLO: SLO{TTFTp99: 2 * time.Second, TPOTp99: 50 * time.Millisecond},
}
}
func (c *Config) normalize() error {
d := DefaultConfig()
if c.Target == "" {
return errors.New("bench: a target is required")
}
if c.Model == "" {
return errors.New("bench: a model is required")
}
c.Target = strings.TrimRight(c.Target, "/")
if len(c.Concurrency) == 0 {
c.Concurrency = d.Concurrency
}
if c.Duration <= 0 {
c.Duration = d.Duration
}
if c.PromptTokens <= 0 {
c.PromptTokens = d.PromptTokens
}
if c.MaxTokens <= 0 {
c.MaxTokens = d.MaxTokens
}
sort.Ints(c.Concurrency)
return nil
}
// Percentiles summarizes a latency sample.
type Percentiles struct {
P50, P90, P99, Max time.Duration
}
// Step is one concurrency level's result.
type Step struct {
Concurrency int
Completed int
Failed int
// Requests and output tokens per second, over the step's wall clock.
RequestsPerSec float64
TokensPerSec float64
TTFT Percentiles
// TPOT is time per output token after the first, which is what a reader
// experiences as the speed of the stream.
TPOT Percentiles
Waiting float64
Running float64
KVUsage float64
MeetsSLO bool
// FirstError is kept so a step that failed says why rather than just
// reporting zeroes.
FirstError string
}
// Result is the whole ramp plus what it concluded.
type Result struct {
Model string
Target string
Steps []Step
SLO SLO
RanAt time.Time
Elapsed time.Duration
// MaxConcurrencyAtSLO is the capacity: the highest level that still met the
// target. Zero means even one request did not.
MaxConcurrencyAtSLO int
// ThroughputTokPerSec and the latencies are taken at that level.
ThroughputTokPerSec float64
TTFTp99, TPOTp99 time.Duration
// SuggestedTargetWaiting and SuggestedKVCeiling are what admission should
// treat as healthy, observed rather than guessed.
SuggestedTargetWaiting int
SuggestedKVCeiling float64
}
// Run executes the ramp.
func Run(ctx context.Context, cfg Config, progress func(Step)) (Result, error) {
if err := cfg.normalize(); err != nil {
return Result{}, err
}
res := Result{Model: cfg.Model, Target: cfg.Target, SLO: cfg.SLO, RanAt: time.Now()}
start := time.Now()
client := &http.Client{Timeout: 10 * time.Minute}
for _, c := range cfg.Concurrency {
if ctx.Err() != nil {
break
}
step := runStep(ctx, client, cfg, c)
res.Steps = append(res.Steps, step)
if progress != nil {
progress(step)
}
// Stop climbing once the target is missed. Everything above is slower
// by construction, and the cost of finding out is real GPU time.
if !step.MeetsSLO {
break
}
}
res.Elapsed = time.Since(start)
res.conclude()
return res, nil
}
func (r *Result) conclude() {
for _, s := range r.Steps {
if !s.MeetsSLO || s.Completed == 0 {
continue
}
if s.Concurrency <= r.MaxConcurrencyAtSLO {
continue
}
r.MaxConcurrencyAtSLO = s.Concurrency
r.ThroughputTokPerSec = s.TokensPerSec
r.TTFTp99, r.TPOTp99 = s.TTFT.P99, s.TPOT.P99
// The queue depth and cache pressure seen while still meeting the SLO
// are exactly what admission should treat as healthy.
r.SuggestedTargetWaiting = int(s.Waiting + 0.5)
r.SuggestedKVCeiling = roundTo(s.KVUsage, 0.05)
}
}
func runStep(ctx context.Context, client *http.Client, cfg Config, concurrency int) Step {
step := Step{Concurrency: concurrency}
ctx, cancel := context.WithTimeout(ctx, cfg.Duration)
defer cancel()
var (
mu sync.Mutex
ttfts []time.Duration
tpots []time.Duration
tokens int64
failed int
firstErr string
)
sampler := newSampler(cfg.MetricsURL, cfg.Engine)
sampler.apiKey = cfg.APIKey
sampler.start(ctx)
start := time.Now()
var wg sync.WaitGroup
for range concurrency {
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
r, err := one(ctx, client, cfg)
mu.Lock()
switch {
case err != nil:
// A cancelled context is the step ending, not a failure.
if ctx.Err() == nil {
failed++
if firstErr == "" {
firstErr = err.Error()
}
}
default:
ttfts = append(ttfts, r.ttft)
if r.tpot > 0 {
tpots = append(tpots, r.tpot)
}
tokens += r.tokens
}
mu.Unlock()
}
}()
}
wg.Wait()
elapsed := time.Since(start)
sampler.stop()
step.Completed = len(ttfts)
step.Failed = failed
step.FirstError = firstErr
step.TTFT = percentiles(ttfts)
step.TPOT = percentiles(tpots)
if elapsed > 0 {
step.RequestsPerSec = float64(step.Completed) / elapsed.Seconds()
step.TokensPerSec = float64(tokens) / elapsed.Seconds()
}
step.Waiting, step.Running, step.KVUsage = sampler.averages()
// A step that could not complete anything has not met anything.
step.MeetsSLO = step.Completed > 0 && step.Failed == 0 &&
cfg.SLO.Met(step.TTFT.P99, step.TPOT.P99)
return step
}
type outcome struct {
ttft time.Duration
tpot time.Duration
tokens int64
}
// one sends a single streaming completion and times it.
func one(ctx context.Context, client *http.Client, cfg Config) (outcome, error) {
body, err := json.Marshal(map[string]any{
"model": cfg.Model,
"stream": true,
"max_tokens": cfg.MaxTokens,
"messages": []map[string]string{
{"role": "user", "content": prompt(cfg)},
},
})
if err != nil {
return outcome{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
cfg.Target+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return outcome{}, err
}
req.Header.Set("Content-Type", "application/json")
if cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
return outcome{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return outcome{}, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
}
var out outcome
var firstAt, lastAt time.Time
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data: ") || strings.HasSuffix(line, "[DONE]") {
continue
}
// Only chunks that carry content count: a role-only first chunk is not
// a token, and counting it would flatter the time to first token.
if !hasContent(line[6:]) {
continue
}
now := time.Now()
if out.tokens == 0 {
firstAt = now
out.ttft = now.Sub(start)
}
lastAt = now
out.tokens++
}
if err := sc.Err(); err != nil {
return outcome{}, err
}
if out.tokens == 0 {
return outcome{}, errors.New("the response carried no content")
}
if out.tokens > 1 {
out.tpot = lastAt.Sub(firstAt) / time.Duration(out.tokens-1)
}
return out, nil
}
func hasContent(payload string) bool {
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
return false
}
for _, c := range chunk.Choices {
if c.Delta.Content != "" {
return true
}
}
return false
}
// prompt builds a request body of roughly the configured size.
//
// Unique per request unless a shared prefix was asked for: repeating one prompt
// lets the engine's cache answer most of the prefill, and the result then
// describes the cache rather than the hardware.
//
// "Shared" has to mean byte-identical. Dropping only the random header while
// still picking words at random would leave every prompt different after the
// first few tokens, so the cache would miss and the flag would quietly measure
// the thing it exists to avoid measuring.
func prompt(cfg Config) string {
var b strings.Builder
if cfg.SharedPrefix {
for i := 0; b.Len() < cfg.PromptTokens*4; i++ {
b.WriteString(words[i%len(words)])
b.WriteByte(' ')
}
} else {
fmt.Fprintf(&b, "%016x %016x ", rand.Uint64(), rand.Uint64()) //nolint:gosec // not security
// Roughly four characters to a token, the same crude ratio the router
// uses; exactness is not what this number is for.
for b.Len() < cfg.PromptTokens*4 {
b.WriteString(words[rand.IntN(len(words))]) //nolint:gosec // not security
b.WriteByte(' ')
}
}
b.WriteString("\n\nSummarize the text above.")
return b.String()
}
var words = strings.Fields(`the quick brown fox jumps over lazy dog while distant
thunder rolls across an empty valley and somewhere a door closes softly in the
dark house where nobody has lived for years although the garden still produces
apples every autumn which fall and rot beneath the trees unnoticed by anyone
except the birds that come each morning to pick at them before the frost`)
func percentiles(v []time.Duration) Percentiles {
if len(v) == 0 {
return Percentiles{}
}
s := make([]time.Duration, len(v))
copy(s, v)
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
at := func(p float64) time.Duration {
i := int(float64(len(s)-1) * p)
return s[i]
}
return Percentiles{P50: at(0.50), P90: at(0.90), P99: at(0.99), Max: s[len(s)-1]}
}
// roundTo rounds to a multiple of step, through integers.
//
// The obvious float arithmetic returns 0.7000000000000001 for what should be
// 0.70, which is harmless on a terminal and ugly in the JSON someone pastes
// into a configuration file.
func roundTo(v, step float64) float64 {
if step <= 0 {
return v
}
const scale = 10000
units := int64(v/step + 0.5)
return float64(int64(float64(units)*step*scale+0.5)) / scale
}