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>
510 lines
11 KiB
Go
510 lines
11 KiB
Go
// Package sim provides an OpenAI-compatible fake inference engine.
|
|
//
|
|
// Its purpose is to let the router be tested under realistic conditions
|
|
// without GPUs. It models the real-engine behaviours the router cares about:
|
|
// - continuous batching (bounded number of concurrent sequences)
|
|
// - KV cache block accounting, queueing once blocks run out
|
|
// - automatic prefix caching (block-level LRU) and its effect on TTFT
|
|
// - per-token latency growing as the batch grows
|
|
// - /metrics in vLLM's format
|
|
package sim
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Config controls the simulated engine's behaviour.
|
|
type Config struct {
|
|
Model string
|
|
|
|
// MaxNumSeqs is how many sequences may run concurrently (batch capacity).
|
|
MaxNumSeqs int
|
|
// BlockSize is the number of tokens per KV cache block.
|
|
BlockSize int
|
|
// NumGPUBlocks is the total number of KV cache blocks.
|
|
NumGPUBlocks int
|
|
// PrefixCacheBlocks is the prefix cache capacity in blocks. 0 disables it.
|
|
PrefixCacheBlocks int
|
|
|
|
// PrefillTokensPerSec is the prefill rate, shared engine-wide.
|
|
PrefillTokensPerSec float64
|
|
// DecodeTokensPerSec is the generation rate for a single running sequence.
|
|
DecodeTokensPerSec float64
|
|
// BatchContention is how much each additional sequence slows decoding:
|
|
// effective rate = DecodeTokensPerSec / (1 + BatchContention*(n-1)).
|
|
BatchContention float64
|
|
|
|
// DefaultOutputTokens is generated when a request omits max_tokens.
|
|
DefaultOutputTokens int
|
|
// Tick is the simulation step; smaller values increase resolution.
|
|
Tick time.Duration
|
|
|
|
// FailRate is the fraction of requests rejected artificially, for chaos tests.
|
|
FailRate float64
|
|
// Seed makes runs deterministic.
|
|
Seed int64
|
|
}
|
|
|
|
// Default returns a configuration with sensible values, roughly in the range
|
|
// of a 70B model on 8xH100.
|
|
func Default() Config {
|
|
return Config{
|
|
Model: "sim-model",
|
|
MaxNumSeqs: 64,
|
|
BlockSize: 16,
|
|
NumGPUBlocks: 4096,
|
|
PrefixCacheBlocks: 8192,
|
|
PrefillTokensPerSec: 12000,
|
|
DecodeTokensPerSec: 60,
|
|
BatchContention: 0.012,
|
|
DefaultOutputTokens: 128,
|
|
Tick: 5 * time.Millisecond,
|
|
}
|
|
}
|
|
|
|
func (c *Config) normalize() {
|
|
if c.Model == "" {
|
|
c.Model = "sim-model"
|
|
}
|
|
if c.MaxNumSeqs <= 0 {
|
|
c.MaxNumSeqs = 64
|
|
}
|
|
if c.BlockSize <= 0 {
|
|
c.BlockSize = 16
|
|
}
|
|
if c.NumGPUBlocks <= 0 {
|
|
c.NumGPUBlocks = 4096
|
|
}
|
|
if c.PrefillTokensPerSec <= 0 {
|
|
c.PrefillTokensPerSec = 12000
|
|
}
|
|
if c.DecodeTokensPerSec <= 0 {
|
|
c.DecodeTokensPerSec = 60
|
|
}
|
|
if c.DefaultOutputTokens <= 0 {
|
|
c.DefaultOutputTokens = 128
|
|
}
|
|
if c.Tick <= 0 {
|
|
c.Tick = 5 * time.Millisecond
|
|
}
|
|
}
|
|
|
|
// EventKind is the type of an event in the stream.
|
|
type EventKind int
|
|
|
|
const (
|
|
// EventToken carries one generated token.
|
|
EventToken EventKind = iota
|
|
// EventDone marks normal completion.
|
|
EventDone
|
|
// EventError reports a failure during generation.
|
|
EventError
|
|
)
|
|
|
|
// Event is a single event delivered to the caller.
|
|
type Event struct {
|
|
Kind EventKind
|
|
Text string
|
|
Err error
|
|
// Result is set only on EventDone; it carries the final token counts.
|
|
Result *Result
|
|
}
|
|
|
|
// Prompt is a request handed to the engine.
|
|
type Prompt struct {
|
|
Text string
|
|
MaxTokens int
|
|
}
|
|
|
|
// Result summarizes a finished request; valid from the Done event onwards.
|
|
type Result struct {
|
|
PromptTokens int
|
|
CachedTokens int
|
|
CompletionTokens int
|
|
}
|
|
|
|
var (
|
|
// ErrTooLong means the request does not fit in the KV cache at all.
|
|
ErrTooLong = errors.New("request does not fit in KV cache capacity")
|
|
// ErrInjected is the artificial failure produced by FailRate.
|
|
ErrInjected = errors.New("injected failure (sim fail_rate)")
|
|
// ErrShutdown is returned to pending requests while the engine stops.
|
|
ErrShutdown = errors.New("engine shutting down")
|
|
)
|
|
|
|
type req struct {
|
|
id string
|
|
ctx context.Context
|
|
prompt Prompt
|
|
|
|
promptTokens int
|
|
cachedTokens int
|
|
outputTokens int
|
|
hashes []BlockHash
|
|
|
|
blocks int
|
|
prefillLeft float64
|
|
decodeLeft int
|
|
decodeAccum float64
|
|
emitted int
|
|
|
|
arrived time.Time
|
|
firstAt time.Time
|
|
lastTokAt time.Time
|
|
|
|
out chan Event
|
|
pending []Event
|
|
finished bool
|
|
res *Result
|
|
}
|
|
|
|
// flush moves buffered events into the channel without blocking.
|
|
// Not blocking is essential: the tick loop holds the engine lock, and one slow
|
|
// client must not stall every other request.
|
|
func (r *req) flush() {
|
|
for len(r.pending) > 0 {
|
|
select {
|
|
case r.out <- r.pending[0]:
|
|
r.pending = r.pending[1:]
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *req) emit(ev Event) {
|
|
r.pending = append(r.pending, ev)
|
|
r.flush()
|
|
}
|
|
|
|
// Engine is the simulated inference engine.
|
|
type Engine struct {
|
|
cfg Config
|
|
|
|
mu sync.Mutex
|
|
waiting []*req
|
|
running []*req
|
|
draining []*req
|
|
freeBlocks int
|
|
cache *PrefixCache
|
|
rng *rand.Rand
|
|
seq atomic.Uint64
|
|
|
|
m *metrics
|
|
|
|
stopOnce sync.Once
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
}
|
|
|
|
// New creates an engine and starts its simulation loop. Call Close to stop it.
|
|
func New(cfg Config) *Engine {
|
|
cfg.normalize()
|
|
seed := cfg.Seed
|
|
if seed == 0 {
|
|
seed = time.Now().UnixNano()
|
|
}
|
|
e := &Engine{
|
|
cfg: cfg,
|
|
freeBlocks: cfg.NumGPUBlocks,
|
|
cache: NewPrefixCache(cfg.PrefixCacheBlocks),
|
|
rng: rand.New(rand.NewSource(seed)), //nolint:gosec // simulation only
|
|
m: newMetrics(),
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
go e.run()
|
|
return e
|
|
}
|
|
|
|
// Config returns the engine's effective configuration.
|
|
func (e *Engine) Config() Config { return e.cfg }
|
|
|
|
// Close stops the simulation loop and fails any pending requests.
|
|
func (e *Engine) Close() {
|
|
e.stopOnce.Do(func() {
|
|
close(e.stop)
|
|
<-e.done
|
|
})
|
|
}
|
|
|
|
// Submit enqueues a request and returns its event channel. The channel closes
|
|
// when generation finishes or fails.
|
|
func (e *Engine) Submit(ctx context.Context, p Prompt) (<-chan Event, error) {
|
|
if e.cfg.FailRate > 0 {
|
|
e.mu.Lock()
|
|
fail := e.rng.Float64() < e.cfg.FailRate
|
|
e.mu.Unlock()
|
|
if fail {
|
|
e.m.failed.Add(1)
|
|
return nil, ErrInjected
|
|
}
|
|
}
|
|
|
|
tokens := Tokenize(p.Text)
|
|
out := p.MaxTokens
|
|
if out <= 0 {
|
|
out = e.cfg.DefaultOutputTokens
|
|
}
|
|
|
|
r := &req{
|
|
id: fmt.Sprintf("sim-%d", e.seq.Add(1)),
|
|
ctx: ctx,
|
|
prompt: p,
|
|
promptTokens: len(tokens),
|
|
outputTokens: out,
|
|
hashes: BlockHashes(tokens, e.cfg.BlockSize),
|
|
arrived: time.Now(),
|
|
out: make(chan Event, 64),
|
|
}
|
|
|
|
if e.blocksFor(r.promptTokens+r.outputTokens) > e.cfg.NumGPUBlocks {
|
|
e.m.failed.Add(1)
|
|
return nil, ErrTooLong
|
|
}
|
|
|
|
e.mu.Lock()
|
|
select {
|
|
case <-e.stop:
|
|
e.mu.Unlock()
|
|
return nil, ErrShutdown
|
|
default:
|
|
}
|
|
e.waiting = append(e.waiting, r)
|
|
e.mu.Unlock()
|
|
|
|
e.m.received.Add(1)
|
|
return r.out, nil
|
|
}
|
|
|
|
func (e *Engine) blocksFor(tokens int) int {
|
|
return (tokens + e.cfg.BlockSize - 1) / e.cfg.BlockSize
|
|
}
|
|
|
|
func (e *Engine) run() {
|
|
defer close(e.done)
|
|
t := time.NewTicker(e.cfg.Tick)
|
|
defer t.Stop()
|
|
dt := e.cfg.Tick.Seconds()
|
|
|
|
for {
|
|
select {
|
|
case <-e.stop:
|
|
e.shutdown()
|
|
return
|
|
case <-t.C:
|
|
e.mu.Lock()
|
|
e.reap()
|
|
e.schedule()
|
|
e.advance(dt)
|
|
e.flushAll()
|
|
e.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// reap drops requests whose client has gone away, from both the queue and the
|
|
// running batch. The router's cancellation propagation cannot be tested
|
|
// without this: when a client disconnects, the GPU must stop working.
|
|
func (e *Engine) reap() {
|
|
e.waiting = filterInPlace(e.waiting, func(r *req) bool {
|
|
if r.ctx.Err() == nil {
|
|
return true
|
|
}
|
|
e.m.cancelled.Add(1)
|
|
close(r.out)
|
|
return false
|
|
})
|
|
e.running = filterInPlace(e.running, func(r *req) bool {
|
|
if r.ctx.Err() == nil {
|
|
return true
|
|
}
|
|
e.freeBlocks += r.blocks
|
|
e.m.cancelled.Add(1)
|
|
close(r.out)
|
|
return false
|
|
})
|
|
}
|
|
|
|
func (e *Engine) schedule() {
|
|
for len(e.waiting) > 0 && len(e.running) < e.cfg.MaxNumSeqs {
|
|
r := e.waiting[0]
|
|
need := e.blocksFor(r.promptTokens + r.outputTokens)
|
|
if need > e.freeBlocks {
|
|
// Head-of-line blocking; real engines behave the same way.
|
|
break
|
|
}
|
|
|
|
hitBlocks := e.cache.Lookup(r.hashes)
|
|
r.cachedTokens = hitBlocks * e.cfg.BlockSize
|
|
r.blocks = need
|
|
r.prefillLeft = float64(r.promptTokens - r.cachedTokens)
|
|
if r.prefillLeft < 0 {
|
|
r.prefillLeft = 0
|
|
}
|
|
r.decodeLeft = r.outputTokens
|
|
|
|
e.freeBlocks -= need
|
|
e.running = append(e.running, r)
|
|
e.waiting = e.waiting[1:]
|
|
}
|
|
}
|
|
|
|
func (e *Engine) advance(dt float64) {
|
|
if len(e.running) == 0 {
|
|
return
|
|
}
|
|
|
|
// The prefill budget is shared engine-wide and handed out FIFO.
|
|
budget := e.cfg.PrefillTokensPerSec * dt
|
|
for _, r := range e.running {
|
|
if budget <= 0 {
|
|
break
|
|
}
|
|
if r.prefillLeft <= 0 {
|
|
continue
|
|
}
|
|
used := min(budget, r.prefillLeft)
|
|
r.prefillLeft -= used
|
|
budget -= used
|
|
}
|
|
|
|
// Decoding slows down as the batch grows.
|
|
n := 0
|
|
for _, r := range e.running {
|
|
if r.prefillLeft <= 0 {
|
|
n++
|
|
}
|
|
}
|
|
if n == 0 {
|
|
return
|
|
}
|
|
rate := e.cfg.DecodeTokensPerSec / (1 + e.cfg.BatchContention*float64(n-1))
|
|
|
|
now := time.Now()
|
|
for _, r := range e.running {
|
|
if r.prefillLeft > 0 || r.finished {
|
|
continue
|
|
}
|
|
r.decodeAccum += rate * dt
|
|
for r.decodeAccum >= 1 && r.decodeLeft > 0 {
|
|
r.decodeAccum--
|
|
r.decodeLeft--
|
|
r.emitted++
|
|
|
|
if r.firstAt.IsZero() {
|
|
r.firstAt = now
|
|
e.m.observeTTFT(now.Sub(r.arrived))
|
|
} else {
|
|
e.m.observeTPOT(now.Sub(r.lastTokAt))
|
|
}
|
|
r.lastTokAt = now
|
|
r.emit(Event{Kind: EventToken, Text: tokenText(r.emitted)})
|
|
}
|
|
if r.decodeLeft == 0 {
|
|
e.finish(r)
|
|
}
|
|
}
|
|
|
|
e.running = filterInPlace(e.running, func(r *req) bool { return !r.finished })
|
|
}
|
|
|
|
func (e *Engine) finish(r *req) {
|
|
r.finished = true
|
|
e.freeBlocks += r.blocks
|
|
|
|
// The generated output enters the cache too: when it comes back as
|
|
// conversation history on the next turn it must hit. That is the only way
|
|
// to model multi-turn chat.
|
|
full := Tokenize(r.prompt.Text + generatedText(r.emitted))
|
|
e.cache.Insert(BlockHashes(full, e.cfg.BlockSize))
|
|
|
|
r.res = &Result{
|
|
PromptTokens: r.promptTokens,
|
|
CachedTokens: r.cachedTokens,
|
|
CompletionTokens: r.emitted,
|
|
}
|
|
e.m.finished.Add(1)
|
|
e.m.promptTokens.Add(int64(r.promptTokens))
|
|
e.m.genTokens.Add(int64(r.emitted))
|
|
|
|
r.emit(Event{Kind: EventDone, Result: r.res})
|
|
e.draining = append(e.draining, r)
|
|
}
|
|
|
|
func (e *Engine) flushAll() {
|
|
for _, r := range e.running {
|
|
r.flush()
|
|
}
|
|
e.draining = filterInPlace(e.draining, func(r *req) bool {
|
|
r.flush()
|
|
if len(r.pending) == 0 {
|
|
close(r.out)
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (e *Engine) shutdown() {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
for _, r := range append(e.waiting, e.running...) {
|
|
select {
|
|
case r.out <- Event{Kind: EventError, Err: ErrShutdown}:
|
|
default:
|
|
}
|
|
close(r.out)
|
|
}
|
|
for _, r := range e.draining {
|
|
close(r.out)
|
|
}
|
|
e.waiting, e.running, e.draining = nil, nil, nil
|
|
}
|
|
|
|
// Stats returns the instantaneous state the router would scrape.
|
|
type Stats struct {
|
|
Running int
|
|
Waiting int
|
|
KVCacheUsage float64
|
|
BlockSize int
|
|
NumGPUBlocks int
|
|
PrefixQueries int64
|
|
PrefixHits int64
|
|
CachedBlocks int
|
|
}
|
|
|
|
// Stats returns a snapshot of the engine's current state.
|
|
func (e *Engine) Stats() Stats {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
used := e.cfg.NumGPUBlocks - e.freeBlocks
|
|
q, h := e.cache.Stats()
|
|
return Stats{
|
|
Running: len(e.running),
|
|
Waiting: len(e.waiting),
|
|
KVCacheUsage: float64(used) / float64(e.cfg.NumGPUBlocks),
|
|
BlockSize: e.cfg.BlockSize,
|
|
NumGPUBlocks: e.cfg.NumGPUBlocks,
|
|
PrefixQueries: q,
|
|
PrefixHits: h,
|
|
CachedBlocks: e.cache.Len(),
|
|
}
|
|
}
|
|
|
|
func filterInPlace(s []*req, keep func(*req) bool) []*req {
|
|
out := s[:0]
|
|
for _, r := range s {
|
|
if keep(r) {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|