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:
JianFeeeee
2026-08-30 08:25:18 +08:00
parent 25d8bd8632
commit 813de19bd0
2 changed files with 112 additions and 28 deletions

View File

@ -106,10 +106,11 @@ type adapterPool struct {
} }
const ( const (
// growDivisor turns the adapter's max concurrency into a growth step: // growDivisor turns the adapter's max concurrency into the growth step CAP:
// step = ceil(maxW / growDivisor), clamped to [1, growStepCap]. A source set // step <= clamp(ceil(maxW / growDivisor), 1, growStepCap). The actual batch
// at max_concurrent=8 grows one state at a time; a 64-wide adapter warms 8 // is additionally bounded by how many callers are queued (see growPlanLocked),
// at once instead of paying 8 sequential boots on a traffic spike. // so a wide adapter may ramp in big jumps but never warms states nobody is
// waiting for.
growDivisor = 8 growDivisor = 8
growStepCap = 8 growStepCap = 8
// growCooldown keeps a burst of misses from batching repeatedly while the // 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 // growPlanLocked reserves capacity for a batch prewarm and returns how many
// EXTRA states to boot in the background (the caller already reserved one for // EXTRA states to boot in the background (the caller already reserved one for
// itself). contended says the miss happened while every existing state was // itself).
// already checked out, which is the real ramp signal: sequential traffic keeps //
// reusing one warm state and must never trigger a batch, while genuinely // Two independent limits apply, and BOTH matter:
// 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. // - 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 { func (p *adapterPool) growPlanLocked(contended bool) int {
if !contended { if !contended || p.waiting <= 0 {
return 0 return 0
} }
if time.Since(p.lastGrow) < growCooldown { if time.Since(p.lastGrow) < growCooldown {
return 0 return 0
} }
extra := p.growStepLocked() - 1 extra := p.growStepLocked() - 1
if extra > p.waiting {
extra = p.waiting // never warm more than the queue needs
}
if extra <= 0 { if extra <= 0 {
return 0 return 0
} }

View File

@ -1217,18 +1217,97 @@ func TestSequentialTrafficDoesNotBatch(t *testing.T) {
} }
} }
// TestContentionBatchPrewarms: when concurrent demand exceeds the warm set, the // TestContentionBatchPrewarms: when callers are actually QUEUED behind a full
// pool warms a whole grow step instead of one state per request. // 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) { func TestContentionBatchPrewarms(t *testing.T) {
vm := NewVM(freshAdapterDir(t)) vm := NewVM(freshAdapterDir(t))
if err := vm.Start(); err != nil { if err := vm.Start(); err != nil {
t.Fatalf("start: %v", err) t.Fatalf("start: %v", err)
} }
defer vm.Stop() 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") 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() w1, err := p.acquire()
if err != nil { if err != nil {
t.Fatalf("acquire 1: %v", err) t.Fatalf("acquire 1: %v", err)
@ -1237,21 +1316,10 @@ func TestContentionBatchPrewarms(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("acquire 2: %v", err) t.Fatalf("acquire 2: %v", err)
} }
// the batch is booted in the background; wait for it to land time.Sleep(100 * time.Millisecond) // give any background prewarm a chance
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)
}
created, _, _ := poolCounts(p) created, _, _ := poolCounts(p)
if created > 32 { if created != 2 {
t.Fatalf("prewarm exceeded the ceiling, created=%d", created) t.Fatalf("two overlapping requests must create exactly 2 states, got %d", created)
} }
p.release(w1) p.release(w1)
p.release(w2) p.release(w2)