CI / test (push) Has been cancelled
Comments, doc comments, test names, error and log messages, CLI help, README, Makefile, CI step names and the design document are now all in English. No behaviour changes; the full suite passes under the race detector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
245 lines
5.6 KiB
Go
245 lines
5.6 KiB
Go
package sim
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testConfig speeds the simulation up so tests stay fast and deterministic.
|
|
func testConfig() Config {
|
|
c := Default()
|
|
c.Tick = time.Millisecond
|
|
c.PrefillTokensPerSec = 1e6
|
|
c.DecodeTokensPerSec = 5000
|
|
c.BatchContention = 0
|
|
c.Seed = 42
|
|
return c
|
|
}
|
|
|
|
// drain collects every token from the channel and waits for completion.
|
|
func drain(t *testing.T, ch <-chan Event) (text string, done bool) {
|
|
t.Helper()
|
|
var b strings.Builder
|
|
deadline := time.After(10 * time.Second)
|
|
for {
|
|
select {
|
|
case ev, open := <-ch:
|
|
if !open {
|
|
return b.String(), done
|
|
}
|
|
switch ev.Kind {
|
|
case EventToken:
|
|
b.WriteString(ev.Text)
|
|
case EventDone:
|
|
done = true
|
|
case EventError:
|
|
t.Fatalf("unexpected error: %v", ev.Err)
|
|
}
|
|
case <-deadline:
|
|
t.Fatal("timed out waiting for tokens")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEngineGeneratesTokens(t *testing.T) {
|
|
e := New(testConfig())
|
|
defer e.Close()
|
|
|
|
ch, err := e.Submit(t.Context(), Prompt{Text: "hello world", MaxTokens: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text, done := drain(t, ch)
|
|
if !done {
|
|
t.Error("no EventDone received")
|
|
}
|
|
if got := len(strings.Fields(text)); got != 10 {
|
|
t.Errorf("expected 10 tokens, got %d (%q)", got, text)
|
|
}
|
|
}
|
|
|
|
func TestCancellationReleasesBlocks(t *testing.T) {
|
|
// Essential for testing the router's cancellation propagation: when the
|
|
// client goes away the engine must drop the request and free its KV blocks.
|
|
cfg := testConfig()
|
|
cfg.DecodeTokensPerSec = 20 // slow, so it is still running when cancelled
|
|
e := New(cfg)
|
|
defer e.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
ch, err := e.Submit(ctx, Prompt{Text: strings.Repeat("x", 4000), MaxTokens: 1000})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
waitFor(t, time.Second, func() bool { return e.Stats().Running == 1 })
|
|
if usage := e.Stats().KVCacheUsage; usage <= 0 {
|
|
t.Fatal("running request reserved no KV blocks")
|
|
}
|
|
|
|
cancel()
|
|
|
|
waitFor(t, 2*time.Second, func() bool {
|
|
s := e.Stats()
|
|
return s.Running == 0 && s.KVCacheUsage == 0
|
|
})
|
|
|
|
// The channel must close so the client side never blocks.
|
|
select {
|
|
case _, open := <-ch:
|
|
if open {
|
|
for range ch { //nolint:revive // drain remaining events
|
|
}
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("channel stayed open after cancellation")
|
|
}
|
|
}
|
|
|
|
func TestRequestsQueueWhenBatchIsFull(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.MaxNumSeqs = 2
|
|
cfg.DecodeTokensPerSec = 20
|
|
e := New(cfg)
|
|
defer e.Close()
|
|
|
|
for range 5 {
|
|
if _, err := e.Submit(t.Context(), Prompt{Text: "abc", MaxTokens: 100}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
waitFor(t, time.Second, func() bool { return e.Stats().Running == 2 })
|
|
s := e.Stats()
|
|
if s.Running != 2 {
|
|
t.Errorf("running=%d exceeds MaxNumSeqs=2", s.Running)
|
|
}
|
|
if s.Waiting != 3 {
|
|
t.Errorf("waiting=%d, expected 3", s.Waiting)
|
|
}
|
|
}
|
|
|
|
func TestOversizedRequestIsRejected(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.NumGPUBlocks = 4
|
|
cfg.BlockSize = 16 // 64 tokens of capacity in total
|
|
e := New(cfg)
|
|
defer e.Close()
|
|
|
|
_, err := e.Submit(t.Context(), Prompt{Text: strings.Repeat("x", 10000), MaxTokens: 100})
|
|
if err != ErrTooLong {
|
|
t.Errorf("expected ErrTooLong, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrefixCacheSpeedsUpRepeatedPrompt(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.PrefillTokensPerSec = 2000 // make prefill cost measurable
|
|
cfg.DecodeTokensPerSec = 5000
|
|
e := New(cfg)
|
|
defer e.Close()
|
|
|
|
prompt := Prompt{Text: strings.Repeat("shared system prompt. ", 200), MaxTokens: 5}
|
|
|
|
first := timeToFirstToken(t, e, prompt)
|
|
// Send the same prompt again now that the first one populated the cache.
|
|
second := timeToFirstToken(t, e, prompt)
|
|
|
|
if second >= first {
|
|
t.Errorf("prefix cache did not reduce TTFT: first=%v second=%v", first, second)
|
|
}
|
|
if e.Stats().PrefixHits == 0 {
|
|
t.Error("prefix cache hit counter did not advance")
|
|
}
|
|
}
|
|
|
|
func TestNoHitsWhenPrefixCacheDisabled(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.PrefixCacheBlocks = 0
|
|
e := New(cfg)
|
|
defer e.Close()
|
|
|
|
p := Prompt{Text: strings.Repeat("same prompt. ", 50), MaxTokens: 3}
|
|
for range 3 {
|
|
ch, err := e.Submit(t.Context(), p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
drain(t, ch)
|
|
}
|
|
if h := e.Stats().PrefixHits; h != 0 {
|
|
t.Errorf("disabled cache reported %d hits", h)
|
|
}
|
|
}
|
|
|
|
func TestShutdownDrainsPendingRequests(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.DecodeTokensPerSec = 1
|
|
e := New(cfg)
|
|
|
|
ch, err := e.Submit(t.Context(), Prompt{Text: "abc", MaxTokens: 1000})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitFor(t, time.Second, func() bool { return e.Stats().Running == 1 })
|
|
e.Close()
|
|
|
|
deadline := time.After(2 * time.Second)
|
|
for {
|
|
select {
|
|
case _, open := <-ch:
|
|
if !open {
|
|
return
|
|
}
|
|
case <-deadline:
|
|
t.Fatal("channel stayed open after shutdown")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInjectedFailure(t *testing.T) {
|
|
cfg := testConfig()
|
|
cfg.FailRate = 1.0
|
|
e := New(cfg)
|
|
defer e.Close()
|
|
|
|
if _, err := e.Submit(t.Context(), Prompt{Text: "abc"}); err != ErrInjected {
|
|
t.Errorf("expected ErrInjected, got %v", err)
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func timeToFirstToken(t *testing.T, e *Engine, p Prompt) time.Duration {
|
|
t.Helper()
|
|
start := time.Now()
|
|
ch, err := e.Submit(t.Context(), p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var ttft time.Duration
|
|
for ev := range ch {
|
|
if ev.Kind == EventToken && ttft == 0 {
|
|
ttft = time.Since(start)
|
|
}
|
|
}
|
|
if ttft == 0 {
|
|
t.Fatal("no tokens were generated")
|
|
}
|
|
return ttft
|
|
}
|
|
|
|
func waitFor(t *testing.T, limit time.Duration, cond func() bool) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(limit)
|
|
for time.Now().Before(deadline) {
|
|
if cond() {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatalf("condition not met within %v", limit)
|
|
}
|