Files
llmgw/internal/bench/bench_test.go
T
Ilker ManapandClaude Opus 5 05bdc7d6d4 Add ctl bench, and fix two holes it walked into
`ctl bench` measures what a replica can actually serve, so weight,
target_waiting and kv_ceiling stop being numbers somebody typed. Concurrency
climbs until the SLO breaks and the last level that met it is the capacity:
peak throughput arrives long after latency has become unusable, so throughput
alone overstates what the hardware can serve. Queue depth and KV usage are
read from the engine while it is still meeting the target, which is exactly
what admission should treat as healthy.

Prompts are unique per request by default. With one repeated prompt the
engine's cache answers most of the prefill and the measurement describes the
cache rather than the hardware; --shared-prefix asks that question
deliberately, and it now produces byte-identical prompts, which the first
version did not — it dropped the random header and still picked words at
random, so the cache missed and the flag quietly measured the thing it exists
to avoid.

Running it against the cluster turned up two real defects:

A replica stuck in Starting never timed out. Every other way out of that state
needs the agent to report something, and when it never does the replica sits
there holding GPUs and counting towards the model's replica count while
serving nothing. Two of them had been like that for eight minutes past a
two-minute startup timeout, and nothing would ever have moved them.

They were stuck because adoption lost to a replica we had only placed. Replica
ids come from the model name and a counter, so a control plane that restarts
and places before its agents reconnect builds a different replica under the
same id on a freshly allocated port; the agent's report of the engine that was
actually serving was then discarded as already-known. The cluster pointed at
port 20004 while the engine answered on 20000. The fleet is authoritative
about what is running, so what we merely placed now loses to what an agent
reports — unless our copy has a shard that has actually come up, in which case
two things really are running and the stray is the one we did not ask for.

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

157 lines
4.8 KiB
Go

package bench
import (
"context"
"strings"
"testing"
"time"
)
func TestCapacityIsTheLastLevelThatMetTheSLONotThePeak(t *testing.T) {
// A GPU keeps delivering more tokens per second long after latency has
// become unusable. Taking peak throughput as capacity would tell the router
// a replica can serve a load at which nobody would want the answers.
r := Result{SLO: SLO{TTFTp99: time.Second}}
r.Steps = []Step{
{Concurrency: 1, Completed: 10, TokensPerSec: 100, MeetsSLO: true,
TTFT: Percentiles{P99: 100 * time.Millisecond}},
{Concurrency: 8, Completed: 80, TokensPerSec: 700, MeetsSLO: true,
TTFT: Percentiles{P99: 900 * time.Millisecond}, Waiting: 3.4, KVUsage: 0.71},
// Faster in tokens, far too slow in latency.
{Concurrency: 32, Completed: 200, TokensPerSec: 1500, MeetsSLO: false,
TTFT: Percentiles{P99: 9 * time.Second}},
}
r.conclude()
if r.MaxConcurrencyAtSLO != 8 {
t.Errorf("capacity = %d, want 8", r.MaxConcurrencyAtSLO)
}
if r.ThroughputTokPerSec != 700 {
t.Errorf("throughput = %v; it must come from the level that met the SLO", r.ThroughputTokPerSec)
}
if r.SuggestedTargetWaiting != 3 {
t.Errorf("target_waiting = %d, want 3", r.SuggestedTargetWaiting)
}
if r.SuggestedKVCeiling != 0.70 {
t.Errorf("kv_ceiling = %v, want 0.70", r.SuggestedKVCeiling)
}
}
func TestAStepWithFailuresHasNotMetAnything(t *testing.T) {
// Latency percentiles over the requests that survived say nothing useful
// when the rest were refused.
r := Result{SLO: SLO{TTFTp99: time.Second}}
r.Steps = []Step{
{Concurrency: 4, Completed: 10, Failed: 90, TokensPerSec: 50, MeetsSLO: false,
TTFT: Percentiles{P99: 50 * time.Millisecond}},
}
r.conclude()
if r.MaxConcurrencyAtSLO != 0 {
t.Errorf("a step that failed 90 of 100 requests was counted as capacity")
}
}
func TestSLOIsMissedWhenEitherHalfIs(t *testing.T) {
s := SLO{TTFTp99: time.Second, TPOTp99: 50 * time.Millisecond}
cases := []struct {
ttft, tpot time.Duration
want bool
}{
{500 * time.Millisecond, 20 * time.Millisecond, true},
{2 * time.Second, 20 * time.Millisecond, false},
{500 * time.Millisecond, 80 * time.Millisecond, false},
{2 * time.Second, 80 * time.Millisecond, false},
}
for _, c := range cases {
if got := s.Met(c.ttft, c.tpot); got != c.want {
t.Errorf("Met(%s, %s) = %v", c.ttft, c.tpot, got)
}
}
// A target of zero is no target, not a target of zero.
none := SLO{}
if !none.Met(time.Hour, time.Hour) {
t.Error("an unset SLO refused everything")
}
}
func TestPercentiles(t *testing.T) {
var v []time.Duration
for i := 1; i <= 100; i++ {
v = append(v, time.Duration(i)*time.Millisecond)
}
p := percentiles(v)
if p.P50 != 50*time.Millisecond || p.P90 != 90*time.Millisecond {
t.Errorf("p50=%s p90=%s", p.P50, p.P90)
}
if p.Max != 100*time.Millisecond {
t.Errorf("max=%s", p.Max)
}
if got := percentiles(nil); got != (Percentiles{}) {
t.Errorf("empty sample gave %+v", got)
}
}
func TestPromptsAreUniqueUnlessAskedNotToBe(t *testing.T) {
// The default matters more than it looks. With one repeated prompt the
// engine's cache answers most of the prefill, and the measurement describes
// the cache rather than the hardware.
cfg := Config{PromptTokens: 64}
seen := map[string]bool{}
for range 100 {
seen[prompt(cfg)] = true
}
if len(seen) < 100 {
t.Errorf("%d distinct prompts out of 100; the cache would answer the rest", len(seen))
}
cfg.SharedPrefix = true
first := prompt(cfg)
for range 20 {
if got := prompt(cfg); !strings.HasPrefix(got, first[:200]) {
t.Error("--shared-prefix did not produce a shared prefix")
}
}
}
func TestPromptSizeFollowsTheRequest(t *testing.T) {
small := prompt(Config{PromptTokens: 32})
large := prompt(Config{PromptTokens: 512})
if len(large) < len(small)*4 {
t.Errorf("a 512-token prompt is %d chars against %d for 32", len(large), len(small))
}
}
func TestConfigIsValidatedBeforeAnyLoadIsGenerated(t *testing.T) {
// The failure has to come before the traffic, not halfway through a ramp.
if _, err := Run(context.Background(), Config{Model: "m"}, nil); err == nil {
t.Error("a run with no target started")
}
if _, err := Run(context.Background(), Config{Target: "http://x"}, nil); err == nil {
t.Error("a run with no model started")
}
}
func TestTheRampStopsOnceTheSLOBreaks(t *testing.T) {
// Everything above a missed level is slower by construction, and finding
// that out costs real GPU time.
r := Result{}
r.Steps = []Step{
{Concurrency: 1, Completed: 1, MeetsSLO: true},
{Concurrency: 2, Completed: 1, MeetsSLO: false},
}
r.conclude()
if r.MaxConcurrencyAtSLO != 1 {
t.Errorf("capacity = %d", r.MaxConcurrencyAtSLO)
}
}
func TestRoundTo(t *testing.T) {
cases := map[float64]float64{0.71: 0.70, 0.73: 0.75, 0.88: 0.90, 0.0: 0.0}
for in, want := range cases {
if got := roundTo(in, 0.05); got != want {
t.Errorf("roundTo(%v) = %v, want %v", in, got, want)
}
}
}