mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
fix(lua): bound prewarm by queue depth, not by the ceiling
Production oscillated between ~46 MB and ~56 MB RSS with the openai pool cycling 1 -> 10..12 -> 1 states every couple of minutes, while peak_in_use never went above 2. Cause: the batch prewarm sized itself purely on the adapter's ceiling. With max_concurrent summing to 76, growStep is 8, so any two overlapping requests warmed 8 states — 6 more than anything was waiting for. A minute later the janitor correctly reclaimed the surplus, the next pair of overlapping requests warmed 8 again, and the pool churned boot/discard forever. The elasticity was working; the growth signal was simply wrong. Prewarm is now bounded by BOTH limits: the ceiling still caps the step, but the batch never exceeds p.waiting, the number of goroutines actually blocked on the pool. Overlapping-but-not-queued traffic (the common case) creates exactly the states it uses; a genuinely queued burst still ramps in one jump. TestContentionBatchPrewarms is rewritten to queue real waiters instead of relying on the ceiling to imply demand, and TestNoPrewarmWithoutWaiters pins the production shape: two overlapping requests against a 76-wide adapter must create exactly 2 states.
This commit is contained in:
@ -106,10 +106,11 @@ type adapterPool struct {
|
||||
}
|
||||
|
||||
const (
|
||||
// growDivisor turns the adapter's max concurrency into a growth step:
|
||||
// step = ceil(maxW / growDivisor), clamped to [1, growStepCap]. A source set
|
||||
// at max_concurrent=8 grows one state at a time; a 64-wide adapter warms 8
|
||||
// at once instead of paying 8 sequential boots on a traffic spike.
|
||||
// growDivisor turns the adapter's max concurrency into the growth step CAP:
|
||||
// step <= clamp(ceil(maxW / growDivisor), 1, growStepCap). The actual batch
|
||||
// is additionally bounded by how many callers are queued (see growPlanLocked),
|
||||
// so a wide adapter may ramp in big jumps but never warms states nobody is
|
||||
// waiting for.
|
||||
growDivisor = 8
|
||||
growStepCap = 8
|
||||
// growCooldown keeps a burst of misses from batching repeatedly while the
|
||||
@ -271,19 +272,34 @@ func (p *adapterPool) boot() (*worker, error) {
|
||||
|
||||
// growPlanLocked reserves capacity for a batch prewarm and returns how many
|
||||
// EXTRA states to boot in the background (the caller already reserved one for
|
||||
// itself). contended says the miss happened while every existing state was
|
||||
// already checked out, which is the real ramp signal: sequential traffic keeps
|
||||
// reusing one warm state and must never trigger a batch, while genuinely
|
||||
// concurrent traffic warms a whole step at once instead of paying one boot per
|
||||
// request all the way up the ramp. Caller holds p.mu.
|
||||
// itself).
|
||||
//
|
||||
// Two independent limits apply, and BOTH matter:
|
||||
//
|
||||
// - the adapter's max concurrency caps the step (growStepLocked), so a
|
||||
// high-concurrency adapter is allowed to ramp in bigger jumps than a
|
||||
// narrow one;
|
||||
// - the number of goroutines actually BLOCKED waiting for a state bounds it
|
||||
// to real demand.
|
||||
//
|
||||
// Sizing on the ceiling alone over-provisions badly: an adapter with
|
||||
// max_concurrent=76 has a step of 8, so two concurrent requests would warm 8
|
||||
// states, and the janitor would throw 7 of them away a minute later — boot,
|
||||
// discard, repeat, with RSS oscillating for no benefit. Prewarming only for
|
||||
// goroutines that are genuinely queued keeps the ramp cheap without the churn.
|
||||
//
|
||||
// Caller holds p.mu.
|
||||
func (p *adapterPool) growPlanLocked(contended bool) int {
|
||||
if !contended {
|
||||
if !contended || p.waiting <= 0 {
|
||||
return 0
|
||||
}
|
||||
if time.Since(p.lastGrow) < growCooldown {
|
||||
return 0
|
||||
}
|
||||
extra := p.growStepLocked() - 1
|
||||
if extra > p.waiting {
|
||||
extra = p.waiting // never warm more than the queue needs
|
||||
}
|
||||
if extra <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -1217,18 +1217,97 @@ func TestSequentialTrafficDoesNotBatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestContentionBatchPrewarms: when concurrent demand exceeds the warm set, the
|
||||
// pool warms a whole grow step instead of one state per request.
|
||||
// TestContentionBatchPrewarms: when callers are actually QUEUED behind a full
|
||||
// pool, the pool warms extra states for them instead of booting one per request.
|
||||
// The batch is bounded by the queue depth, not by the ceiling, so it can never
|
||||
// warm states nobody is waiting for.
|
||||
func TestContentionBatchPrewarms(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
vm.ConfigureConcurrency(map[string]int{"openai": 32}) // grow step = 4
|
||||
// ceiling 32 -> step cap 4; queue depth will be the real bound
|
||||
vm.ConfigureConcurrency(map[string]int{"openai": 32})
|
||||
p := poolOf(t, vm, "openai")
|
||||
|
||||
// hold the first state, then miss while it is busy -> contended
|
||||
// Fill the pool to its ceiling-independent state: hold 1, then queue 3 more
|
||||
// so waiting == 3 when the next miss happens.
|
||||
p.mu.Lock()
|
||||
p.maxW = 1 // force the next acquires to queue
|
||||
p.mu.Unlock()
|
||||
w1, err := p.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire 1: %v", err)
|
||||
}
|
||||
done := make(chan *worker, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
go func() {
|
||||
w, err := p.acquire()
|
||||
if err != nil {
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
done <- w
|
||||
}()
|
||||
}
|
||||
// wait until all three are queued
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
p.mu.Lock()
|
||||
w := p.waiting
|
||||
p.mu.Unlock()
|
||||
if w >= 3 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("waiters never queued, waiting=%d", w)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
// raise the ceiling: the next miss sees waiting=3 and prewarms a batch
|
||||
p.setMax(32)
|
||||
got := make([]*worker, 0, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case w := <-done:
|
||||
if w == nil {
|
||||
t.Fatal("a queued acquire failed")
|
||||
}
|
||||
got = append(got, w)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("queued acquires never completed after the ceiling was raised")
|
||||
}
|
||||
}
|
||||
created, _, _ := poolCounts(p)
|
||||
if created < 4 {
|
||||
t.Fatalf("queued demand must be satisfied, created=%d want >=4", created)
|
||||
}
|
||||
if created > 32 {
|
||||
t.Fatalf("prewarm exceeded the ceiling, created=%d", created)
|
||||
}
|
||||
p.release(w1)
|
||||
for _, w := range got {
|
||||
p.release(w)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoPrewarmWithoutWaiters is the production regression this bound fixes: two
|
||||
// concurrent requests against a wide adapter (ceiling 76, step 8) must NOT warm
|
||||
// eight states. Warming for nobody meant the janitor discarded the surplus a
|
||||
// minute later, so RSS oscillated between ~46 MB and ~56 MB forever.
|
||||
func TestNoPrewarmWithoutWaiters(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
vm.ConfigureConcurrency(map[string]int{"openai": 76})
|
||||
p := poolOf(t, vm, "openai")
|
||||
|
||||
// two overlapping requests: the second misses while the first holds its
|
||||
// state, but nobody is blocked, so no batch may be warmed
|
||||
w1, err := p.acquire()
|
||||
if err != nil {
|
||||
t.Fatalf("acquire 1: %v", err)
|
||||
@ -1237,21 +1316,10 @@ func TestContentionBatchPrewarms(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("acquire 2: %v", err)
|
||||
}
|
||||
// the batch is booted in the background; wait for it to land
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
created, _, _ := poolCounts(p)
|
||||
if created >= 5 { // 2 checked out + 3 prewarmed
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("contended miss did not batch prewarm, created=%d", created)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // give any background prewarm a chance
|
||||
created, _, _ := poolCounts(p)
|
||||
if created > 32 {
|
||||
t.Fatalf("prewarm exceeded the ceiling, created=%d", created)
|
||||
if created != 2 {
|
||||
t.Fatalf("two overlapping requests must create exactly 2 states, got %d", created)
|
||||
}
|
||||
p.release(w1)
|
||||
p.release(w2)
|
||||
|
||||
Reference in New Issue
Block a user