A vLLM model can carry PEFT adapters, registered at start, or with max_loaded_adapters held at most N per replica: the router loads an adapter through vLLM's runtime LoRA API when a request first wants it, sends the adapter's requests to a replica that holds it while that one is not saturated, loads each cold adapter once, and unloads the least recently used idle one to make room. llama.cpp keeps loading every adapter at start. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
512 lines
15 KiB
Go
512 lines
15 KiB
Go
package router
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/router/scheduler"
|
|
)
|
|
|
|
// LoRA adapters that load on demand (a model's max_loaded_adapters).
|
|
//
|
|
// The router decides which replica holds which adapter, and asks vLLM to
|
|
// load and unload them itself, with the engine key it already sends
|
|
// requests with. Not the control plane through the agent: a load is on the
|
|
// path of the request that wants it, and a round trip through the control
|
|
// plane's stream to a node and back would add its latency to every cold
|
|
// adapter for nothing — the router reaches the engine directly, the agent
|
|
// has already fetched every adapter and linked it where vLLM can find it,
|
|
// and the router knows the load on every replica, which is what choosing
|
|
// one is about.
|
|
//
|
|
// What each replica holds is learned from two places: the loads and unloads
|
|
// this router made, and the engine's own /v1/models, read when the router
|
|
// first meets a replica and every so often after — which corrects for a
|
|
// router restart, a replica restart and another router doing the same.
|
|
|
|
const (
|
|
// adapterLoadTimeout bounds one load. An adapter is tens to hundreds of
|
|
// megabytes read from local disk; one that takes minutes is a replica
|
|
// in trouble, and its requests are better sent elsewhere.
|
|
adapterLoadTimeout = 2 * time.Minute
|
|
// adapterSyncTimeout bounds one read of a replica's /v1/models.
|
|
adapterSyncTimeout = 5 * time.Second
|
|
// adapterSyncEvery is how often what the replicas hold is read back.
|
|
adapterSyncEvery = 30 * time.Second
|
|
)
|
|
|
|
// loraTracker knows which adapters each replica holds and loads more.
|
|
type loraTracker struct {
|
|
client *http.Client
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
// observe counts loads and evictions, by model; nil counts nothing.
|
|
observe func(model, what, outcome string)
|
|
|
|
mu sync.Mutex
|
|
reps map[string]*replicaAdapters
|
|
}
|
|
|
|
// replicaAdapters is one replica's adapters.
|
|
type replicaAdapters struct {
|
|
info endpoint.Info
|
|
model string
|
|
// synced is true once the engine's own list has been read.
|
|
synced bool
|
|
held map[string]*heldAdapter
|
|
// loading and unloading are in progress; a request for an adapter being
|
|
// unloaded waits for that before loading it again, so the two calls do
|
|
// not cross on the way to the engine.
|
|
loading map[string]*adapterLoad
|
|
unloading map[string]chan struct{}
|
|
}
|
|
|
|
type heldAdapter struct {
|
|
lastUsed time.Time
|
|
loadedAt time.Time
|
|
// inUse counts requests running on it here, which make it no candidate
|
|
// for eviction.
|
|
inUse int
|
|
}
|
|
|
|
type adapterLoad struct {
|
|
done chan struct{}
|
|
err error
|
|
}
|
|
|
|
func newLoraTracker(log *slog.Logger, observe func(model, what, outcome string)) *loraTracker {
|
|
return &loraTracker{
|
|
client: &http.Client{Timeout: adapterLoadTimeout},
|
|
log: log,
|
|
now: time.Now,
|
|
observe: observe,
|
|
reps: map[string]*replicaAdapters{},
|
|
}
|
|
}
|
|
|
|
// rep is a replica's state, made on first sight. Caller holds mu.
|
|
func (t *loraTracker) rep(info endpoint.Info, model string) *replicaAdapters {
|
|
st, ok := t.reps[info.ID]
|
|
if !ok {
|
|
st = &replicaAdapters{
|
|
held: map[string]*heldAdapter{}, loading: map[string]*adapterLoad{},
|
|
unloading: map[string]chan struct{}{},
|
|
}
|
|
t.reps[info.ID] = st
|
|
}
|
|
st.info, st.model = info, model
|
|
return st
|
|
}
|
|
|
|
// view is where an adapter is among the candidates, for the scheduler.
|
|
func (t *loraTracker) view(eps []endpoint.Snapshot, adapter string, limit int) *scheduler.AdapterView {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
v := &scheduler.AdapterView{Holders: map[string]bool{}, Free: map[string]bool{}}
|
|
for _, e := range eps {
|
|
st, ok := t.reps[e.ID]
|
|
if !ok {
|
|
v.Free[e.ID] = true
|
|
continue
|
|
}
|
|
_, held := st.held[adapter]
|
|
_, loading := st.loading[adapter]
|
|
v.Holders[e.ID] = held || loading
|
|
v.Free[e.ID] = len(st.held)+len(st.loading) < limit
|
|
}
|
|
return v
|
|
}
|
|
|
|
// syncUnknown reads the adapters of the candidates the router has not met
|
|
// yet, all at once, so the first decision after a restart already knows
|
|
// what each replica holds.
|
|
func (t *loraTracker) syncUnknown(ctx context.Context, eps []endpoint.Snapshot, model string) {
|
|
var wg sync.WaitGroup
|
|
t.mu.Lock()
|
|
for _, e := range eps {
|
|
if st, ok := t.reps[e.ID]; ok && st.synced {
|
|
continue
|
|
}
|
|
t.rep(e.Info, model)
|
|
wg.Add(1)
|
|
go func(info endpoint.Info) {
|
|
defer wg.Done()
|
|
t.sync(ctx, info)
|
|
}(e.Info)
|
|
}
|
|
t.mu.Unlock()
|
|
wg.Wait()
|
|
}
|
|
|
|
// sync reads a replica's /v1/models and takes it as what the replica holds.
|
|
//
|
|
// vLLM lists each adapter it has registered as a model whose parent is the
|
|
// base model. An adapter loaded while the list was on its way is kept: the
|
|
// list is older than the load.
|
|
func (t *loraTracker) sync(ctx context.Context, info endpoint.Info) {
|
|
started := t.now()
|
|
names, err := t.list(ctx, info)
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
st, ok := t.reps[info.ID]
|
|
if !ok {
|
|
return
|
|
}
|
|
// Marked even on a failure: the replica is then taken to hold what this
|
|
// router loaded on it, and the next sync tries again. Blocking every
|
|
// request on a replica whose list will not come would be worse.
|
|
st.synced = true
|
|
if err != nil {
|
|
t.log.Debug("could not read a replica's adapters", "endpoint", info.ID, "err", err)
|
|
return
|
|
}
|
|
listed := map[string]bool{}
|
|
for _, n := range names {
|
|
listed[n] = true
|
|
if _, ok := st.held[n]; !ok {
|
|
// Loaded by someone else, or before this router started: least
|
|
// recently used as far as this router knows, so first to go.
|
|
st.held[n] = &heldAdapter{}
|
|
}
|
|
}
|
|
for n, h := range st.held {
|
|
if !listed[n] && h.loadedAt.Before(started) && h.inUse == 0 {
|
|
delete(st.held, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// list asks a replica which adapters it has registered.
|
|
func (t *loraTracker) list(ctx context.Context, info endpoint.Info) ([]string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, adapterSyncTimeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, info.URL+"/v1/models", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+info.APIKey)
|
|
}
|
|
resp, err := t.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("models: %s", resp.Status)
|
|
}
|
|
var body struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
Parent *string `json:"parent"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("models: %w", err)
|
|
}
|
|
var out []string
|
|
for _, m := range body.Data {
|
|
if m.Parent != nil && *m.Parent != "" {
|
|
out = append(out, m.ID)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// resyncAll reads back what every replica the router has met holds, and
|
|
// forgets the ones that have gone.
|
|
func (t *loraTracker) resyncAll(ctx context.Context) {
|
|
t.mu.Lock()
|
|
infos := make([]endpoint.Info, 0, len(t.reps))
|
|
for _, st := range t.reps {
|
|
infos = append(infos, st.info)
|
|
}
|
|
t.mu.Unlock()
|
|
for _, info := range infos {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
t.sync(ctx, info)
|
|
}
|
|
}
|
|
|
|
// run resyncs until ctx ends.
|
|
func (t *loraTracker) run(ctx context.Context) {
|
|
tick := time.NewTicker(adapterSyncEvery)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-tick.C:
|
|
t.resyncAll(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// keep forgets every replica not among ids.
|
|
func (t *loraTracker) keep(ids map[string]struct{}) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
for id := range t.reps {
|
|
if _, ok := ids[id]; !ok {
|
|
delete(t.reps, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// forget records that a replica turned out not to hold an adapter after
|
|
// all: it answered a request for it with "no such model".
|
|
func (t *loraTracker) forget(id, adapter string) {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
if st, ok := t.reps[id]; ok {
|
|
delete(st.held, adapter)
|
|
}
|
|
}
|
|
|
|
// Affinity results, for the metric and the tests: the replica held the
|
|
// adapter, was already loading it for another request, or loaded it for
|
|
// this one.
|
|
const (
|
|
adapterHit = "hit"
|
|
adapterJoined = "joined"
|
|
adapterLoaded = "loaded"
|
|
)
|
|
|
|
// errAdapterLoad is a load the engine refused: the adapter itself is wrong
|
|
// — not found, not a LoRA for this model, a rank above --max-lora-rank —
|
|
// and every other replica would refuse it too.
|
|
var errAdapterLoad = errors.New("the adapter could not be loaded")
|
|
|
|
// acquire makes sure a replica holds an adapter, loading it there if it
|
|
// does not, and holds it there until release is called: an adapter a
|
|
// request is running on is never the one unloaded to make room.
|
|
//
|
|
// One load per adapter per replica at a time. A request that finds the
|
|
// adapter being loaded waits for that load instead of starting another, and
|
|
// the scheduler counts a replica that is loading an adapter as holding it,
|
|
// so two requests for one cold adapter load it once — on one replica,
|
|
// unless that one is too busy to take the second.
|
|
func (t *loraTracker) acquire(ctx context.Context, info endpoint.Info, model, adapter, path string, limit int) (string, func(), error) {
|
|
how := adapterHit
|
|
for {
|
|
t.mu.Lock()
|
|
st := t.rep(info, model)
|
|
if h, ok := st.held[adapter]; ok {
|
|
h.inUse++
|
|
h.lastUsed = t.now()
|
|
t.mu.Unlock()
|
|
return how, t.releaser(h), nil
|
|
}
|
|
if l, ok := st.loading[adapter]; ok {
|
|
t.mu.Unlock()
|
|
select {
|
|
case <-l.done:
|
|
case <-ctx.Done():
|
|
return "", nil, ctx.Err()
|
|
}
|
|
if l.err != nil {
|
|
return "", nil, l.err
|
|
}
|
|
if how == adapterHit {
|
|
how = adapterJoined
|
|
}
|
|
continue
|
|
}
|
|
if ch, ok := st.unloading[adapter]; ok {
|
|
t.mu.Unlock()
|
|
select {
|
|
case <-ch:
|
|
case <-ctx.Done():
|
|
return "", nil, ctx.Err()
|
|
}
|
|
continue
|
|
}
|
|
|
|
l := &adapterLoad{done: make(chan struct{})}
|
|
st.loading[adapter] = l
|
|
victims := st.victims(adapter, limit)
|
|
for _, v := range victims {
|
|
delete(st.held, v)
|
|
st.unloading[v] = make(chan struct{})
|
|
}
|
|
t.mu.Unlock()
|
|
// On its own, not on the request: the client going away must not
|
|
// abandon a load other requests are waiting for.
|
|
go t.load(st, info, model, adapter, path, victims, l)
|
|
how = adapterLoaded
|
|
}
|
|
}
|
|
|
|
// victims are the adapters to unload so that one more fits under the
|
|
// limit: the least recently used of those no request is running on. When
|
|
// every one is busy the replica goes over the limit for now, and the next
|
|
// load brings it back — vLLM itself only needs the files, and refusing the
|
|
// request would serve no one. Caller holds mu.
|
|
func (st *replicaAdapters) victims(adding string, limit int) []string {
|
|
var out []string
|
|
count := len(st.held) + len(st.loading)
|
|
for count > limit {
|
|
victim := ""
|
|
var oldest time.Time
|
|
for n, h := range st.held {
|
|
if n == adding || h.inUse > 0 || slices.Contains(out, n) {
|
|
continue
|
|
}
|
|
if victim == "" || h.lastUsed.Before(oldest) || (h.lastUsed.Equal(oldest) && n < victim) {
|
|
victim, oldest = n, h.lastUsed
|
|
}
|
|
}
|
|
if victim == "" {
|
|
break
|
|
}
|
|
out = append(out, victim)
|
|
count--
|
|
}
|
|
return out
|
|
}
|
|
|
|
// load unloads the victims, then loads the adapter, and tells whoever is
|
|
// waiting.
|
|
func (t *loraTracker) load(st *replicaAdapters, info endpoint.Info, model, adapter, path string, victims []string, l *adapterLoad) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), adapterLoadTimeout)
|
|
defer cancel()
|
|
for _, v := range victims {
|
|
err := t.call(ctx, info, "/v1/unload_lora_adapter", map[string]string{"lora_name": v})
|
|
if err != nil && !notLoaded(err) {
|
|
t.log.Warn("could not unload an adapter to make room; the replica holds one more than its limit",
|
|
"endpoint", info.ID, "adapter", v, "err", err)
|
|
t.count(model, "evict", "failed")
|
|
} else {
|
|
t.count(model, "evict", "ok")
|
|
}
|
|
t.mu.Lock()
|
|
close(st.unloading[v])
|
|
delete(st.unloading, v)
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
err := t.call(ctx, info, "/v1/load_lora_adapter", map[string]string{"lora_name": adapter, "lora_path": path})
|
|
if err != nil && alreadyLoaded(err) {
|
|
err = nil
|
|
}
|
|
now := t.now()
|
|
t.mu.Lock()
|
|
delete(st.loading, adapter)
|
|
if err == nil {
|
|
st.held[adapter] = &heldAdapter{lastUsed: now, loadedAt: now}
|
|
}
|
|
l.err = err
|
|
close(l.done)
|
|
t.mu.Unlock()
|
|
if err != nil {
|
|
t.log.Warn("could not load an adapter", "endpoint", info.ID, "adapter", adapter, "path", path, "err", err)
|
|
t.count(model, "load", "failed")
|
|
return
|
|
}
|
|
t.log.Info("adapter loaded", "endpoint", info.ID, "adapter", adapter, "unloaded", victims)
|
|
t.count(model, "load", "ok")
|
|
}
|
|
|
|
func (t *loraTracker) count(model, what, outcome string) {
|
|
if t.observe != nil {
|
|
t.observe(model, what, outcome)
|
|
}
|
|
}
|
|
|
|
// releaser is the release for one acquire, safe to call more than once.
|
|
func (t *loraTracker) releaser(h *heldAdapter) func() {
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
t.mu.Lock()
|
|
h.inUse--
|
|
h.lastUsed = t.now()
|
|
t.mu.Unlock()
|
|
})
|
|
}
|
|
}
|
|
|
|
// engineError is a non-success answer from the engine's adapter routes.
|
|
type engineError struct {
|
|
status int
|
|
msg string
|
|
}
|
|
|
|
func (e *engineError) Error() string { return fmt.Sprintf("engine answered %d: %s", e.status, e.msg) }
|
|
|
|
// Is makes a refusal of the adapter itself an errAdapterLoad. A 4xx is
|
|
// about what was asked; a 5xx, or no answer, is about the replica.
|
|
func (e *engineError) Is(target error) bool {
|
|
return target == errAdapterLoad && e.status >= 400 && e.status < 500
|
|
}
|
|
|
|
// call posts to one of vLLM's adapter routes. Both answer 200 with a line of
|
|
// text on success, and an OpenAI-style error otherwise.
|
|
func (t *loraTracker) call(ctx context.Context, info endpoint.Info, path string, body any) error {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, info.URL+path, bytes.NewReader(b))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if info.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+info.APIKey)
|
|
}
|
|
resp, err := t.client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
|
if resp.StatusCode == http.StatusOK {
|
|
return nil
|
|
}
|
|
var e struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
Message string `json:"message"`
|
|
}
|
|
text := strings.TrimSpace(string(msg))
|
|
if json.Unmarshal(msg, &e) == nil {
|
|
if e.Error.Message != "" {
|
|
text = e.Error.Message
|
|
} else if e.Message != "" {
|
|
text = e.Message
|
|
}
|
|
}
|
|
return &engineError{status: resp.StatusCode, msg: text}
|
|
}
|
|
|
|
// alreadyLoaded is vLLM refusing a load because the adapter is there: what
|
|
// the load was for.
|
|
func alreadyLoaded(err error) bool {
|
|
var e *engineError
|
|
return errors.As(err, &e) && e.status == http.StatusBadRequest && strings.Contains(e.msg, "already been loaded")
|
|
}
|
|
|
|
// notLoaded is vLLM refusing an unload because the adapter is not there,
|
|
// which is what the unload was for. Newer vLLM says so with a 404, older
|
|
// with a 400.
|
|
func notLoaded(err error) bool {
|
|
var e *engineError
|
|
return errors.As(err, &e) && (e.status == http.StatusNotFound ||
|
|
e.status == http.StatusBadRequest && strings.Contains(e.msg, "cannot be found"))
|
|
}
|