The GPU is a size-1 resource, so a single long job monopolises the box for its
whole duration and every interactive request queues behind it. Callers can now
declare intent with an X-LlamaSwap-Priority header and the serial scheduler
dispatches by score instead of by arrival.
- X-LlamaSwap-Priority: signed integer, 0 default, absent/unparseable means 0.
interactive/normal/batch aliases resolve to +100/0/-100. Values are not
clamped: the caller composes band and any per-user offset itself.
- serial dispatch score = priority + swap affinity + aging. Bands sit 100 apart
so a small caller offset orders work inside a band without crossing one;
aging is unbounded so low-priority work cannot starve.
- routing.scheduler.settings.serial.{agingDivisor,swapAffinityBonus}, defaulting
to 60s/point and +10. swapAffinityBonus is capped at 99 so it can never
promote a request into the next band.
- fifo adds the header to its per-model priority, so the header is not silently
ignored under that scheduler.
- /metrics exports per-band queue depth, oldest wait and dispatch counts, plus
counters for how often aging or swap affinity changed the pick. Each request
records its priority, band, score and queue wait in the activity log.
Note swapAffinityBonus defaults to 10, so equal-priority requests for the
already-loaded model now run before older requests that need a swap. Set it to
0 for the previous strict arrival order.
fixes #9
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WUyhZBgv8BBCC5MduX88gE
182 lines
7.7 KiB
Go
182 lines
7.7 KiB
Go
// Package scheduler contains the request-scheduling strategies used by the
|
|
// router's baseRouter. A Scheduler owns the queue, in-flight tracking, and the
|
|
// decision tree for when to start a swap versus queue a request. The baseRouter
|
|
// owns the channels, run loop, and process machinery, and exposes the
|
|
// side-effects a scheduler needs through the Effects interface.
|
|
//
|
|
// Splitting these apart lets the scheduling strategy be swapped out
|
|
// independently of both the process machinery (baseRouter) and the eviction
|
|
// policy (Swapper). FIFO is the first and currently only implementation.
|
|
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/mostlygeek/llama-swap/internal/config"
|
|
"github.com/mostlygeek/llama-swap/internal/logmon"
|
|
"github.com/mostlygeek/llama-swap/internal/process"
|
|
"github.com/mostlygeek/llama-swap/internal/shared"
|
|
)
|
|
|
|
// ErrModelNotFound is granted to callers whose model is not handled by this
|
|
// router. It is an alias for shared.ErrNoLocalModelFound.
|
|
var ErrModelNotFound = shared.ErrNoLocalModelFound
|
|
|
|
// Swapper is the eviction policy: it decides which running models must be
|
|
// stopped before a target can serve. It is orthogonal to the scheduling
|
|
// strategy — any Scheduler works with any Swapper.
|
|
type Swapper interface {
|
|
// EvictionFor returns running model IDs that must be stopped before
|
|
// target can serve. running is the complete set the scheduler considers
|
|
// live: every process that is not stopped, unioned with the targets of
|
|
// in-flight swaps the scheduler has already committed to (which are not yet
|
|
// visible in process state). The planner does not inspect process state
|
|
// itself. Pure decision; must not log.
|
|
EvictionFor(target string, running []string) []string
|
|
|
|
// OnSwapStart runs once at the start of every swap, with the same running
|
|
// set EvictionFor was given for this decision. Planners may log their
|
|
// decision here at whatever verbosity they choose.
|
|
OnSwapStart(target string, running []string)
|
|
}
|
|
|
|
// Scheduler decides what happens to each event the router's run loop receives.
|
|
// All methods run on that single run-loop goroutine, so implementations need no
|
|
// internal locking for their own state.
|
|
type Scheduler interface {
|
|
// OnRequest handles one incoming ServeHTTP request.
|
|
OnRequest(req HandlerReq)
|
|
// OnCancel handles a request whose client has disconnected before it was
|
|
// granted. The scheduler must remove the request from its queue and from
|
|
// any in-flight swap's waiters so it never triggers a model load or grant
|
|
// for a caller that is no longer there.
|
|
OnCancel(req HandlerReq)
|
|
// OnSwapDone handles a swap goroutine reporting completion.
|
|
OnSwapDone(ev SwapDone)
|
|
// OnServeDone handles a tracked ServeHTTP finishing (in-flight decrement).
|
|
OnServeDone(ev ServeDoneEvent)
|
|
// OnUnload reconciles scheduler state for an unload, stops the targeted
|
|
// processes via Effects, and drains the queue. It must block until the
|
|
// targeted processes have stopped.
|
|
OnUnload(targets []string, timeout time.Duration)
|
|
// OnShutdown grants err to every waiter the scheduler still holds (active
|
|
// swap waiters and queued requests). Process teardown is the baseRouter's
|
|
// responsibility.
|
|
OnShutdown(err error)
|
|
}
|
|
|
|
// Effects is implemented by the baseRouter. The scheduler calls back through it
|
|
// for every side-effect: inspecting process state, launching swaps, responding
|
|
// to callers, and stopping processes.
|
|
type Effects interface {
|
|
// ModelState returns the current state of a model's process. ok is false
|
|
// when the model is not handled by this router.
|
|
ModelState(modelID string) (process.ProcessState, bool)
|
|
// RunningModels returns the state of every process that is not stopped or
|
|
// shut down, keyed by model ID. The scheduler uses it to build the running
|
|
// set it hands the Swapper.
|
|
RunningModels() map[string]process.ProcessState
|
|
// StartSwap launches the swap goroutine for modelID, stopping evict first.
|
|
StartSwap(modelID string, evict []string)
|
|
// GrantError responds to a caller with an error.
|
|
GrantError(req HandlerReq, err error)
|
|
// GrantServe hands a caller the wrapped handler for modelID and reports
|
|
// whether the caller was still there to receive it. The scheduler bumps
|
|
// its in-flight count only when this returns true.
|
|
GrantServe(req HandlerReq, modelID string) bool
|
|
// StopProcesses stops the named processes in parallel and blocks until all
|
|
// have stopped. Unknown IDs are skipped.
|
|
StopProcesses(timeout time.Duration, ids []string)
|
|
}
|
|
|
|
// New returns a Scheduler selected by conf.Routing.Scheduler.Use, configured from
|
|
// conf and bound to the given planner and effects. Supported values are "fifo"
|
|
// (throughput-oriented, batches same-model requests) and "serial" (strict
|
|
// one-model-at-a-time, highest-priority-first).
|
|
//
|
|
// The deployment default is applied by config loading (LoadConfig sets Use to
|
|
// "serial" when unset). The "" fallback here is the library default and remains
|
|
// "fifo" so callers that build a Config directly keep the original behavior.
|
|
func New(conf config.Config, name string, logger *logmon.Monitor, planner Swapper, eff Effects) (Scheduler, error) {
|
|
use := conf.Routing.Scheduler.Use
|
|
if use == "" {
|
|
use = "fifo"
|
|
}
|
|
switch use {
|
|
case "fifo":
|
|
return NewFIFO(name, logger, planner, conf.Routing.Scheduler.Settings.Fifo, conf.Models, eff), nil
|
|
case "serial":
|
|
// Serial ignores the group planner: it always evicts every other model.
|
|
return NewSerial(name, logger, conf.Routing.Scheduler.Settings.Serial, eff), nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported scheduler type: %q", use)
|
|
}
|
|
}
|
|
|
|
// HandlerReq is one in-flight ServeHTTP request waiting for a routing decision.
|
|
type HandlerReq struct {
|
|
Model string
|
|
// Priority is the caller's scheduling priority from the
|
|
// X-LlamaSwap-Priority header. Higher is more urgent, 0 is normal, and
|
|
// negative is batch work. Schedulers use it to order their queues.
|
|
Priority int
|
|
Ctx context.Context
|
|
Respond chan HandlerResp
|
|
PositionCh chan int
|
|
}
|
|
|
|
// BandStats is the queue state for one priority band.
|
|
type BandStats struct {
|
|
// Depth is how many requests are waiting in this band.
|
|
Depth int
|
|
// OldestWait is how long the longest-waiting request in this band has
|
|
// been queued, or 0 when the band is empty.
|
|
OldestWait time.Duration
|
|
// Dispatched counts requests in this band handed off since start.
|
|
Dispatched uint64
|
|
}
|
|
|
|
// QueueStats is a point-in-time snapshot of a scheduler's queue, for metrics.
|
|
// The reorder counters answer "did the scoring function actually change
|
|
// anything?" — without them, tuning aging and swap affinity is guesswork.
|
|
type QueueStats struct {
|
|
// Bands is keyed by shared.PriorityBand and always carries an entry for
|
|
// every band in shared.PriorityBands, so metric series stay stable.
|
|
Bands map[string]BandStats
|
|
// AgingReorders counts dispatches where the aging term changed which
|
|
// request was picked.
|
|
AgingReorders uint64
|
|
// AffinityReorders counts dispatches where the swap-affinity term changed
|
|
// which request was picked.
|
|
AffinityReorders uint64
|
|
}
|
|
|
|
// StatsReporter is implemented by schedulers that can report queue statistics.
|
|
// It is optional: callers type-assert and skip schedulers that do not provide
|
|
// it. Unlike the Scheduler methods, QueueStats is called from arbitrary
|
|
// goroutines (the /metrics handler) and must be safe for concurrent use.
|
|
type StatsReporter interface {
|
|
QueueStats() QueueStats
|
|
}
|
|
|
|
// HandlerResp is the routing decision returned to a HandlerReq's caller: either
|
|
// a handler to serve with, or an error.
|
|
type HandlerResp struct {
|
|
HandleFunc http.HandlerFunc
|
|
Err error
|
|
}
|
|
|
|
// SwapDone is reported by a swap goroutine when its target is ready (or failed).
|
|
type SwapDone struct {
|
|
ModelID string
|
|
Err error
|
|
}
|
|
|
|
// ServeDoneEvent is reported when a tracked ServeHTTP handler returns.
|
|
type ServeDoneEvent struct {
|
|
ModelID string
|
|
}
|