mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
* feat(diagnostics): add private opt-in profiler Keep profiling on a dedicated loopback listener rather than the public API. Validate settings and bind synchronously so startup reports configuration and socket failures. Own sampling and shutdown for the run process. Cover private handlers, blocked cmdline access, sampling cleanup, and serving failures with focused Ginkgo tests. Assisted-by: nib:gpt-6-astra * feat(diagnostics): add sanitized phase timing Carry generated request and reload identities through private contexts. Keep timing disabled without allocations and emit only validated fields at Info level when enabled. Cover identity isolation, nested durations, inheritance, and sanitized logging with focused race-tested Ginkgo specs. Assisted-by: nib:gpt-6-astra * fix(diagnostics): retain request observations Reuse an existing request observation across recorders to avoid splitting correlation. Keep the original context, identity, and sink without new allocations. Preserve nil-recorder behavior and fresh reload identities. Verify the regression fails before the fix and passes with the full Diagnostics race suite afterward. Assisted-by: nib:gpt-6-astra * feat(cli): own opt-in diagnostics lifecycle Expose profiling and phase timing flags without enabling them for debug. Bind the private profiler before application initialization and propagate serving failures through the run lifecycle. Bound signal cleanup and preserve preload-only backend startup. Cover flag parsing, preload isolation, cancellation, and once-only shutdown. Document initialization and shutdown cancellation limitations. Assisted-by: nib:gpt-6-astra * feat(config): measure filter and reload phases Separate request lock waits from filtering and held work. Aggregate reload filesystem and parsing durations without logging inside the config lock. Preserve caller option execution under the lock after metadata collection. Extract only the internal diagnostics option before waiting for the lock. Keep direct callers uninstrumented unless they supply diagnostics options. Verify focused Diagnostics examples with the race detector, including option ordering, gated contention, errors, panic propagation, and parity. Assisted-by: nib:gpt-6-astra * fix(config): separate early reload observation Opaque callbacks cannot select timing before their execution point. Carry the startup recorder explicitly through loader construction. Apply late options once under the lock and honor composed last writes. Document early and late phase boundaries and observation identities. Report malformed gallery syntax as an internal parse failure without changing strict or non-strict loader returns. Assisted-by: nib:gpt-6-astra * fix(config): preserve per-model option callbacks The resolved snapshot suppresses existing callbacks during model defaults, even when diagnostics are disabled. Pass original options through the existing defaults pathway without adding diagnostics-driven replay. Test stateful options with two models in enabled and disabled modes. Preserve three calls and thread defaults of two and three. Assisted-by: nib:gpt-6-astra * feat(model): observe discovery phases Carry request diagnostics through config and loose-file discovery to separate directory enumeration from in-memory filtering and fallback. Keep existing APIs as background-context wrappers and preserve discovery policies, ordering, duplicates, errors, and filesystem operation counts. Add focused parity, privacy, phase, and disabled-allocation tests. The focused diagnostics race gate passes for model and galleryop. Assisted-by: nib:gpt-6-astra * feat(http): observe extraction phases Generate diagnostic identity before request discovery, independently of client correlation headers. End each extraction segment before inference so downstream latency does not inflate discovery measurements. Carry observations through discovery helpers and rebuilt request contexts without changing cancellation or model selection. Cover disabled parity, error outcomes, privacy, concurrent identity isolation and route contexts. Assisted-by: nib:gpt-6-astra * fix(http): retain diagnostics across failover Failover restores its entry request before retrying. Initialize the observation before that capture so direct extraction retains one ID. Keep extraction spans per attempt and end them before downstream work. Exercise two real targets without default middleware, including replay, shared identity, phase boundaries, and timing-off response parity. Assisted-by: nib:gpt-6-astra * feat(model): observe initialization and routing Mark loader entry before cache and admission checks. Measure only the ModelRouter callback, without attributing transport or worker latency. Test inherited identity and sanitized events with fake backends. Exercise the actual Echo router to keep profiles private across debug and timing options. Cover capture shutdown, forced close, and lifecycle failures. Assisted-by: nib:gpt-6-astra * docs(diagnostics): guide bounded investigations Document private profiling, sampling ownership, and phase boundaries so operators can interpret captures without assuming request attribution. Add Ginkgo drift checks for flags, defaults, phases, links, and capture warnings. These checks do not certify the operator prose. The focused documentation race test passes after four expected failures. The consolidated Diagnostics race gate passes across all eight packages. This gate does not validate the full suite, auth, or production UI. Assisted-by: nib:gpt-6-astra * refactor(cli): restore inline run lifecycle Remove diagnostic startup hooks so application startup stays readable in Run. Keep the profiler as an optional sidecar with bounded cleanup and an error watcher, without a general lifecycle runner. Replace hook simulations with real CLI startup checks and component capture tests. Document that application cleanup can exceed the private listener budget. The eight-package Diagnostics race gate passes. A cleanup mutation fails the real CLI listener-release regression as expected. Assisted-by: nib:gpt-6-astra * fix(cli): retain concurrent startup errors Cancellation must not hide a public API bind failure. Join the public serve result, cancellation cause, and HTTP shutdown errors inline. Ignore ErrServerClosed only after intentional shutdown. Exercise real startup with an occupied API port and a failed private listener. Isolate the Linux socket fixture in a subprocess and wait for cancellation acknowledgment without timing sleeps. Assisted-by: nib:gpt-6-astra * refactor(http): make request context primary Move context preparation into the request extractor. Diagnostics remain an optional addition, not the entry point for request processing. Preserve context initialization before failover captures the request. Assisted-by: nib:gpt-6-astra --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
587 lines
20 KiB
Go
587 lines
20 KiB
Go
package model
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/pkg/diagnostics"
|
|
grpc "github.com/mudler/LocalAI/pkg/grpc"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
processManager "github.com/mudler/go-processmanager"
|
|
"github.com/mudler/xlog"
|
|
"github.com/phayes/freeport"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const (
|
|
LLamaCPP = "llama-cpp"
|
|
IKLLamaCPP = "ik-llama-cpp"
|
|
)
|
|
|
|
var Aliases = map[string]string{
|
|
"go-llama": LLamaCPP,
|
|
"llama": LLamaCPP,
|
|
"ik_llama": IKLLamaCPP,
|
|
"ik-llama": IKLLamaCPP,
|
|
"embedded-store": LocalStoreBackend,
|
|
"valkey": ValkeyStoreBackend,
|
|
"huggingface-embeddings": TransformersBackend,
|
|
"transformers-musicgen": TransformersBackend,
|
|
"sentencetransformers": TransformersBackend,
|
|
"mamba": TransformersBackend,
|
|
"stablediffusion": StableDiffusionGGMLBackend,
|
|
}
|
|
|
|
var TypeAlias = map[string]string{
|
|
"sentencetransformers": "SentenceTransformer",
|
|
"huggingface-embeddings": "SentenceTransformer",
|
|
"mamba": "Mamba",
|
|
"transformers-musicgen": "MusicgenForConditionalGeneration",
|
|
}
|
|
|
|
const (
|
|
WhisperBackend = "whisper"
|
|
StableDiffusionGGMLBackend = "stablediffusion-ggml"
|
|
Trellis2CppBackend = "trellis2cpp"
|
|
|
|
TransformersBackend = "transformers"
|
|
LocalStoreBackend = "local-store"
|
|
ValkeyStoreBackend = "valkey-store"
|
|
|
|
// Proxy backends serve a model by forwarding to another server instead
|
|
// of loading weights. Core special-cases both (credentials, failover
|
|
// kind, PII defaults), so every check goes through these names.
|
|
CloudProxyBackend = "cloud-proxy"
|
|
LocalAIProxyBackend = "localai-proxy"
|
|
)
|
|
|
|
// starts the grpcModelProcess for the backend, and returns a grpc client
|
|
// It also loads the model
|
|
func (ml *ModelLoader) grpcModel(backend string, o *Options) func(string, string, string) (*Model, error) {
|
|
return func(modelID, modelName, modelFile string) (*Model, error) {
|
|
|
|
xlog.Debug("Loading Model with gRPC", "modelID", modelID, "file", modelFile, "backend", backend, "options", *o)
|
|
|
|
// Distributed mode: delegate to the model router if set. No load
|
|
// event is emitted here: this branch runs per inference request and
|
|
// the actual load happens on the worker node.
|
|
ml.mu.Lock()
|
|
router := ml.modelRouter
|
|
ml.mu.Unlock()
|
|
if router != nil {
|
|
xlog.Info("Routing model to remote node via ModelRouter", "modelID", modelID, "backend", backend)
|
|
// This measures the callback, not transport or worker execution.
|
|
end := diagnostics.Begin(o.context, diagnostics.PhaseModelRouterCallback)
|
|
model, err := router(o.context, backend, modelID, modelName, modelFile, o.configRevision, o.gRPCOptions, o.parallelRequests)
|
|
outcome := diagnostics.OutcomeOK
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
outcome = diagnostics.OutcomeCanceled
|
|
} else if err != nil {
|
|
outcome = diagnostics.OutcomeError
|
|
}
|
|
end(outcome, 0)
|
|
return model, err
|
|
}
|
|
|
|
uri := ml.GetAllExternalBackends(o)[backend]
|
|
start := time.Now()
|
|
started := BackendLoadEvent{ModelID: modelID, ModelName: modelName, Backend: backend, BackendURI: uri}
|
|
finish, admissionErr := ml.notifyLoadStarted(started)
|
|
if admissionErr != nil {
|
|
return nil, admissionErr
|
|
}
|
|
m, err := ml.spawnGRPCModel(backend, uri, o, modelID, modelName, modelFile)
|
|
completed := BackendLoadEvent{
|
|
ModelID: modelID,
|
|
ModelName: modelName,
|
|
Backend: backend,
|
|
BackendURI: uri,
|
|
Duration: time.Since(start),
|
|
Err: err,
|
|
}
|
|
ml.notifyLoadObserver(completed)
|
|
if finish != nil {
|
|
finish(completed)
|
|
}
|
|
return m, err
|
|
}
|
|
}
|
|
|
|
// spawnGRPCModel starts the backend process (or attaches to a remote
|
|
// address), waits for it to come up, and issues the LoadModel RPC. Reached
|
|
// only for actual loads: LoadModel resolves cache hits and coalesces
|
|
// concurrent loads before invoking the grpcModel closure. uri is the
|
|
// resolved external-backend runtime (empty when the backend isn't
|
|
// registered).
|
|
func (ml *ModelLoader) spawnGRPCModel(backend, uri string, o *Options, modelID, modelName, modelFile string) (*Model, error) {
|
|
var client *Model
|
|
|
|
getFreeAddress := func() (string, error) {
|
|
port, err := freeport.GetFreePort()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed allocating free ports: %s", err.Error())
|
|
}
|
|
return fmt.Sprintf("127.0.0.1:%d", port), nil
|
|
}
|
|
|
|
// If no specific model path is set for transformers/HF, set it to the model path
|
|
for _, env := range []string{"HF_HOME", "TRANSFORMERS_CACHE", "HUGGINGFACE_HUB_CACHE"} {
|
|
if os.Getenv(env) == "" {
|
|
err := os.Setenv(env, ml.ModelPath)
|
|
if err != nil {
|
|
xlog.Error("unable to set environment variable to modelPath", "error", err, "name", env, "modelPath", ml.ModelPath)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if the backend is provided as external
|
|
if uri != "" {
|
|
xlog.Debug("Loading external backend", "uri", uri)
|
|
// check if uri is a file or an address
|
|
if fi, err := os.Stat(uri); err == nil {
|
|
xlog.Debug("external backend is file", "file", fi)
|
|
serverAddress, err := getFreeAddress()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed allocating free ports: %s", err.Error())
|
|
}
|
|
// Make sure the process is executable
|
|
process, err := ml.StartProcess(uri, modelID, serverAddress, o.envVars)
|
|
if err != nil {
|
|
xlog.Error("failed to launch", "error", err, "path", uri)
|
|
return nil, err
|
|
}
|
|
|
|
xlog.Debug("GRPC Service Started")
|
|
|
|
client = NewModel(modelID, serverAddress, process)
|
|
} else {
|
|
xlog.Debug("external backend is a uri")
|
|
// address
|
|
client = NewModel(modelID, uri, nil)
|
|
}
|
|
} else {
|
|
xlog.Error("Backend not found", "backend", backend)
|
|
return nil, fmt.Errorf("backend not found: %s", backend)
|
|
}
|
|
|
|
xlog.Debug("Wait for the service to start up")
|
|
xlog.Debug("Options", "options", o.gRPCOptions)
|
|
|
|
// Wait for the service to start up
|
|
ready := false
|
|
for i := range o.grpcAttempts {
|
|
alive, err := client.GRPC(o.parallelRequests, ml.wd).HealthCheck(context.Background())
|
|
if alive {
|
|
xlog.Debug("GRPC Service Ready")
|
|
ready = true
|
|
break
|
|
}
|
|
if err != nil && i == o.grpcAttempts-1 {
|
|
xlog.Error("failed starting/connecting to the gRPC service", "error", err)
|
|
}
|
|
time.Sleep(time.Duration(o.grpcAttemptsDelay) * time.Second)
|
|
}
|
|
|
|
if !ready {
|
|
xlog.Debug("GRPC Service NOT ready")
|
|
startupErr := grpcStartupError(client.Process())
|
|
ml.stopLoadProcess(client, modelID)
|
|
return nil, startupErr
|
|
}
|
|
|
|
// Clone before setting the per-load fields: o.gRPCOptions is shared by
|
|
// retried/auto-discovery attempts, and a plain struct copy would copy
|
|
// the protobuf message's internal mutex.
|
|
options := proto.Clone(o.gRPCOptions).(*pb.ModelOptions)
|
|
options.Model = modelName
|
|
options.ModelFile = modelFile
|
|
options.ModelPath = ml.ModelPath
|
|
|
|
xlog.Debug("GRPC: Loading model with options", "options", options)
|
|
|
|
res, err := client.GRPC(o.parallelRequests, ml.wd).LoadModel(o.context, options)
|
|
if err != nil {
|
|
ml.stopLoadProcess(client, modelID)
|
|
return nil, fmt.Errorf("could not load model: %w", err)
|
|
}
|
|
if !res.Success {
|
|
ml.stopLoadProcess(client, modelID)
|
|
return nil, fmt.Errorf("could not load model (no success): %s", res.Message)
|
|
}
|
|
|
|
// Register size for size-aware eviction using the caller-supplied estimate
|
|
// (computed via pkg/vram, which handles multi-file and non-GGUF models).
|
|
if ml.wd != nil && o.modelSizeBytes > 0 {
|
|
ml.wd.RegisterModelSize(modelID, o.modelSizeBytes)
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
const startupStderrTailBytes = 4096
|
|
|
|
func grpcStartupError(process *processManager.Process) error {
|
|
if process == nil {
|
|
return errors.New("grpc service not ready")
|
|
}
|
|
|
|
select {
|
|
case <-process.Done():
|
|
default:
|
|
return errors.New("grpc service not ready")
|
|
}
|
|
|
|
exitCode, err := process.ExitCode()
|
|
if err != nil {
|
|
return errors.New("grpc service not ready: backend process exited before becoming ready")
|
|
}
|
|
|
|
diagnostic := lastNonEmptyLine(process.StderrPath(), startupStderrTailBytes)
|
|
if diagnostic == "" {
|
|
return fmt.Errorf("grpc service not ready: backend process exited with code %s", strings.TrimSpace(exitCode))
|
|
}
|
|
return fmt.Errorf("grpc service not ready: backend process exited with code %s: %s", strings.TrimSpace(exitCode), diagnostic)
|
|
}
|
|
|
|
func lastNonEmptyLine(path string, maxBytes int64) string {
|
|
// #nosec G304 -- path comes from the process manager for this backend's stderr file.
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
start := max(info.Size()-maxBytes, 0)
|
|
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
|
return ""
|
|
}
|
|
contents, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(contents)), "\n")
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(lines[len(lines)-1])
|
|
}
|
|
|
|
// stopLoadProcess tears down a backend process whose load did not complete.
|
|
// The stop error is only logged: the load error is what the caller reports.
|
|
func (ml *ModelLoader) stopLoadProcess(client *Model, modelID string) {
|
|
process := client.Process()
|
|
if process == nil {
|
|
return
|
|
}
|
|
if err := process.Stop(); err != nil {
|
|
xlog.Warn("failed to stop backend process after failed load", "error", err, "modelID", modelID)
|
|
}
|
|
ml.cleanupProcessRuntime(process)
|
|
}
|
|
|
|
// parallelSlotsFromOptions returns the effective n_parallel from the backend
|
|
// option strings ("parallel:N" / "n_parallel:N"), or "1" when unset — the
|
|
// llama.cpp default. Used only for the effective-tuning load log.
|
|
func parallelSlotsFromOptions(opts []string) string {
|
|
for _, o := range opts {
|
|
k, v, ok := strings.Cut(o, ":")
|
|
if ok && (k == "parallel" || k == "n_parallel") {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return "1"
|
|
}
|
|
|
|
func (ml *ModelLoader) backendLoader(opts ...Option) (client grpc.Backend, err error) {
|
|
o := NewOptions(opts...)
|
|
|
|
// backendLoader is not a synonym for "a model is being loaded": in
|
|
// distributed mode Load() deliberately bypasses the local cache and calls
|
|
// it on every inference request so SmartRouter re-picks a replica per
|
|
// request. Announcing a load unconditionally therefore made ordinary
|
|
// traffic against a resident model look like a retry storm in the INFO
|
|
// log. Reserve INFO for a genuine cold load; keep the per-call trace at
|
|
// DEBUG for anyone actually following the routing path.
|
|
coldLoad := !ml.isResident(o.modelID)
|
|
|
|
if coldLoad {
|
|
xlog.Info("BackendLoader starting", "modelID", o.modelID, "backend", o.backendString, "model", o.model)
|
|
} else {
|
|
xlog.Debug("BackendLoader invoked for an already-resident model", "modelID", o.modelID, "backend", o.backendString, "model", o.model)
|
|
}
|
|
|
|
// Surface the effective performance-relevant runtime options at load (some of
|
|
// these are auto-tuned for the detected hardware). Logged once per load so an
|
|
// admin can see what will actually run and pin or override any value in the
|
|
// model YAML — or set LOCALAI_DISABLE_HARDWARE_DEFAULTS=true to turn the
|
|
// hardware auto-tuning off entirely. Gated on an LLM-ish load (context set) so
|
|
// TTS/audio/other backends stay quiet, and on a cold load so it stays "once
|
|
// per load" rather than once per request.
|
|
if opt := o.gRPCOptions; coldLoad && opt != nil && opt.ContextSize > 0 {
|
|
xlog.Info("effective runtime tuning (override in the model YAML; LOCALAI_DISABLE_HARDWARE_DEFAULTS=true disables hardware auto-tuning)",
|
|
"modelID", o.modelID,
|
|
"context", opt.ContextSize,
|
|
"n_batch", opt.NBatch,
|
|
"n_gpu_layers", opt.NGPULayers,
|
|
"parallel", parallelSlotsFromOptions(opt.Options),
|
|
"flash_attention", opt.FlashAttention,
|
|
"f16", opt.F16Memory)
|
|
}
|
|
|
|
backend := strings.ToLower(o.backendString)
|
|
if realBackend, exists := Aliases[backend]; exists {
|
|
typeAlias, exists := TypeAlias[backend]
|
|
if exists {
|
|
xlog.Debug("alias is a type alias", "alias", backend, "realBackend", realBackend, "type", typeAlias)
|
|
o.gRPCOptions.Type = typeAlias
|
|
} else {
|
|
xlog.Debug("alias", "alias", backend, "realBackend", realBackend)
|
|
}
|
|
|
|
backend = realBackend
|
|
}
|
|
|
|
modelFileName := o.modelFile
|
|
if modelFileName == "" {
|
|
modelFileName = o.model
|
|
}
|
|
model, err := ml.LoadModelWithFile(o.modelID, o.model, modelFileName, ml.grpcModel(backend, o))
|
|
if err != nil {
|
|
// Defensive cleanup: the model usually wasn't registered yet (LoadModel
|
|
// failed before that), so StopGRPC reporting "model not found" is the
|
|
// expected case, not an error. The outer Failed-to-load log below
|
|
// carries the real reason.
|
|
if stopErr := ml.StopGRPC(only(o.modelID)); stopErr != nil {
|
|
xlog.Debug("cleanup stop after failed load", "error", stopErr, "model", o.modelID)
|
|
}
|
|
// A model held by a failed distributed load, or still loading, is an
|
|
// answer to retry later, not a fault of this request.
|
|
var retry interface{ RetryLater() bool }
|
|
if errors.As(err, &retry) && retry.RetryLater() {
|
|
xlog.Info("Model is not available yet", "modelID", o.modelID, "reason", err)
|
|
} else {
|
|
xlog.Error("Failed to load model", "modelID", o.modelID, "error", err, "backend", o.backendString)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return model.GRPC(o.parallelRequests, ml.wd), nil
|
|
}
|
|
|
|
// retryEnforce repeatedly invokes fn until it returns NeedMore=false or the
|
|
// retry budget is exhausted. It sleeps `retryInterval` between attempts and
|
|
// logs progress under `label`. Used by both LRU and group-exclusivity
|
|
// enforcement so the busy-model wait behaviour is identical.
|
|
func retryEnforce(fn func() EnforceLRULimitResult, maxRetries int, retryInterval time.Duration, label string) {
|
|
for attempt := range maxRetries {
|
|
result := fn()
|
|
if !result.NeedMore {
|
|
if result.EvictedCount > 0 {
|
|
xlog.Info("[ModelLoader] "+label+" enforcement complete", "evicted", result.EvictedCount)
|
|
}
|
|
return
|
|
}
|
|
if attempt < maxRetries-1 {
|
|
xlog.Info("[ModelLoader] Waiting for busy models to become idle before eviction",
|
|
"label", label,
|
|
"evicted", result.EvictedCount,
|
|
"attempt", attempt+1,
|
|
"maxRetries", maxRetries,
|
|
"retryIn", retryInterval)
|
|
time.Sleep(retryInterval)
|
|
} else {
|
|
xlog.Warn("[ModelLoader] "+label+" enforcement incomplete after max retries",
|
|
"evicted", result.EvictedCount,
|
|
"reason", "conflicts are still busy or pinned")
|
|
}
|
|
}
|
|
}
|
|
|
|
// enforceLRULimit enforces the LRU limit before loading a new model.
|
|
// This is called before loading a model to ensure we don't exceed the limit.
|
|
// It accounts for models that are currently being loaded by other goroutines.
|
|
// If models are busy and can't be evicted, it will wait and retry until space is available.
|
|
func (ml *ModelLoader) enforceLRULimit() {
|
|
if ml.wd == nil {
|
|
return
|
|
}
|
|
|
|
pendingLoads := ml.GetLoadingCount()
|
|
|
|
ml.mu.Lock()
|
|
maxRetries := ml.lruEvictionMaxRetries
|
|
retryInterval := ml.lruEvictionRetryInterval
|
|
ml.mu.Unlock()
|
|
|
|
retryEnforce(func() EnforceLRULimitResult {
|
|
return ml.wd.EnforceLRULimit(pendingLoads)
|
|
}, maxRetries, retryInterval, "LRU")
|
|
}
|
|
|
|
// enforceGroupExclusivity evicts every loaded model that shares a concurrency
|
|
// group with modelID before loading proceeds. Reuses the LRU retry settings so
|
|
// busy conflicts wait for the same window as a busy LRU eviction.
|
|
func (ml *ModelLoader) enforceGroupExclusivity(modelID string) {
|
|
if ml.wd == nil {
|
|
return
|
|
}
|
|
|
|
ml.mu.Lock()
|
|
maxRetries := ml.lruEvictionMaxRetries
|
|
retryInterval := ml.lruEvictionRetryInterval
|
|
ml.mu.Unlock()
|
|
|
|
retryEnforce(func() EnforceLRULimitResult {
|
|
return ml.wd.EnforceGroupExclusivity(modelID)
|
|
}, maxRetries, retryInterval, "group-exclusivity")
|
|
}
|
|
|
|
// updateModelLastUsed updates the last used time for a model (for LRU tracking)
|
|
func (ml *ModelLoader) updateModelLastUsed(m *Model) {
|
|
if ml.wd == nil || m == nil {
|
|
return
|
|
}
|
|
ml.wd.UpdateLastUsed(m.address)
|
|
}
|
|
|
|
func (ml *ModelLoader) Load(opts ...Option) (grpc.Backend, error) {
|
|
o := NewOptions(opts...)
|
|
diagnostics.Mark(o.context, diagnostics.PhaseModelInit)
|
|
|
|
ml.mu.Lock()
|
|
distributed := ml.modelRouter != nil
|
|
ml.mu.Unlock()
|
|
|
|
// In distributed mode, SmartRouter must run per inference request so
|
|
// PickBestReplica (core/services/nodes/replicapicker.go) picks the
|
|
// least-loaded replica each time. Bypass the local cache and the local
|
|
// LRU / concurrency-group watchdog enforcement: both are scoped to the
|
|
// in-process Model store, which in distributed mode only holds stubs for
|
|
// remote replicas. SmartRouter handles cluster-wide eviction
|
|
// (evictLRUAndFreeNode) and concurrency-group anti-affinity
|
|
// (narrowByGroupAntiAffinity) at the scheduler layer.
|
|
//
|
|
// TODO(distributed-cache): see LoadModel for the rotating-replica-cache
|
|
// integration point that would let hot paths skip the per-request DB
|
|
// round-trip without giving up the shared PickBestReplica policy.
|
|
if distributed {
|
|
client, err := ml.backendLoader(opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if m := ml.CheckIsLoaded(o.modelID); m != nil && m.Process() == nil {
|
|
client = newConnectionEvictingClient(client, o.modelID, func() {
|
|
if err := ml.ShutdownModel(o.modelID); err != nil {
|
|
xlog.Warn("Failed to shut down remote model after connection error", "model", o.modelID, "error", err)
|
|
}
|
|
})
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// Return earlier if we have a model already loaded
|
|
// (avoid looping through all the backends)
|
|
if m := ml.CheckIsLoaded(o.modelID); m != nil {
|
|
xlog.Debug("Model already loaded", "model", o.modelID)
|
|
// Update last used time for LRU tracking
|
|
ml.updateModelLastUsed(m)
|
|
client := m.GRPC(o.parallelRequests, ml.wd)
|
|
// Wrap remote models so connection errors during inference trigger eviction
|
|
if m.Process() == nil {
|
|
client = newConnectionEvictingClient(client, o.modelID, func() {
|
|
if err := ml.ShutdownModel(o.modelID); err != nil {
|
|
xlog.Debug("evicting a model after its connection failed", "model", o.modelID, "error", err)
|
|
}
|
|
})
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// Evict any loaded model that shares a concurrency group with the
|
|
// requested one before applying the global LRU cap — group eviction may
|
|
// already make room, and otherwise LRU might evict an unrelated model
|
|
// only for the group check to immediately evict another.
|
|
ml.enforceGroupExclusivity(o.modelID)
|
|
|
|
// Enforce LRU limit before loading a new model
|
|
ml.enforceLRULimit()
|
|
|
|
// if a backend is defined, return the loader directly
|
|
if o.backendString != "" {
|
|
client, err := ml.backendLoader(opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Wrap remote models so connection errors during inference trigger eviction
|
|
if m := ml.CheckIsLoaded(o.modelID); m != nil && m.Process() == nil {
|
|
client = newConnectionEvictingClient(client, o.modelID, func() {
|
|
if err := ml.ShutdownModel(o.modelID); err != nil {
|
|
xlog.Debug("evicting a model after its connection failed", "model", o.modelID, "error", err)
|
|
}
|
|
})
|
|
}
|
|
return client, nil
|
|
}
|
|
|
|
// Otherwise scan for backends in the asset directory
|
|
var err error
|
|
|
|
// Collect the installed/external backends (the map is unordered).
|
|
available := []string{}
|
|
for b := range ml.GetAllExternalBackends(o) {
|
|
available = append(available, b)
|
|
}
|
|
|
|
// Build a deterministic, file-type-filtered candidate list so an
|
|
// incompatible backend (e.g. an audio codec like opus) can never win the
|
|
// trial loop for a GGUF/LLM model. See SelectAutoLoadBackends / #9287.
|
|
autoLoadBackends := SelectAutoLoadBackends(available, o.model)
|
|
|
|
if len(autoLoadBackends) == 0 {
|
|
xlog.Error("No backends found")
|
|
return nil, fmt.Errorf("no backends found")
|
|
}
|
|
|
|
xlog.Debug("Loading from the following backends (in order)", "backends", autoLoadBackends)
|
|
|
|
xlog.Info("Trying to load the model", "modelID", o.modelID, "backends", autoLoadBackends)
|
|
|
|
for _, key := range autoLoadBackends {
|
|
xlog.Info("Attempting to load", "backend", key)
|
|
options := append(opts, []Option{
|
|
WithBackendString(key),
|
|
}...)
|
|
|
|
model, modelerr := ml.backendLoader(options...)
|
|
if modelerr == nil && model != nil {
|
|
xlog.Info("Loads OK", "backend", key)
|
|
// Wrap remote models so connection errors during inference trigger eviction
|
|
if m := ml.CheckIsLoaded(o.modelID); m != nil && m.Process() == nil {
|
|
model = newConnectionEvictingClient(model, o.modelID, func() {
|
|
if err := ml.ShutdownModel(o.modelID); err != nil {
|
|
xlog.Debug("evicting a model after its connection failed", "model", o.modelID, "error", err)
|
|
}
|
|
})
|
|
}
|
|
return model, nil
|
|
} else if modelerr != nil {
|
|
err = errors.Join(err, fmt.Errorf("[%s]: %w", key, modelerr))
|
|
xlog.Info("Fails", "backend", key, "error", modelerr.Error())
|
|
} else if model == nil {
|
|
err = errors.Join(err, fmt.Errorf("backend %s returned no usable model", key))
|
|
xlog.Info("Fails", "backend", key, "error", "backend returned no usable model")
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("could not load model - all backends returned error: %s", err.Error())
|
|
}
|