`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>
157 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|