Skip to content

Commit b50b1fe

Browse files
authored
feat(watchdog): add size-aware LRU eviction mode (#9527)
* feat(watchdog): add size-aware LRU eviction mode When the model count hits the LRU limit or the memory reclaimer fires, evict the largest model by on-disk file size first rather than the least-recently-used one. For GGUF models the file size is a reliable proxy for GPU/RAM footprint, so evicting the largest candidate maximises freed memory per eviction round while keeping small utility models (embeddings, classifiers, rerankers) resident. Changes: - `pkg/model/watchdog.go`: add `sizeAwareEviction` flag and `modelSizes map[string]int64` to `WatchDog`; sort candidates by `sizeBytes` desc (LRU time as tiebreaker) when the flag is set; add `RegisterModelSize`, `SetSizeAwareEviction`, `GetSizeAwareEviction` - `pkg/model/watchdog_options.go`: add `WithSizeAwareEviction` option - `pkg/model/initializers.go`: stat model file after load and call `RegisterModelSize` so size data is available before the first eviction - `core/config/application_config.go`, `runtime_settings.go`: add `SizeAwareEviction` field and `WithSizeAwareEviction` app option; expose via `ToRuntimeSettings` / `ApplyRuntimeSettings` for the `POST /api/settings` live-reload path - `core/cli/run.go`: add `--size-aware-eviction` flag / `LOCALAI_SIZE_AWARE_EVICTION` env var - `core/application/startup.go`, `watchdog.go`: wire the new option through to `NewWatchDog` - `pkg/model/watchdog_test.go`: 5 new specs — option enable, dynamic toggle, largest-first ordering, equal-size LRU tiebreaker, no-size fallback to LRU, and size-map cleanup on eviction Closes #9375 Signed-off-by: supermario_leo <leo.stack@outlook.com> * refactor(watchdog): use vram estimation scaffolding for model size Replace the brittle os.Stat(modelFile) approach with a proper call to pkg/vram, which handles multi-file models (DownloadFiles, MMProj) and all weight file types, not just single GGUF files. - Add estimateModelSizeBytes() in core/backend/options.go that collects all weight file URIs from the model config, resolves them to file:// URIs, and calls vram.Estimate() with the shared DefaultCachedSizeResolver (15-min TTL cache avoids redundant stat calls on repeated loads) - Thread the result through via a new WithModelSizeBytes() loader option - In initializers.go, consume the pre-computed size instead of calling os.Stat; if no size was supplied (e.g. for external/router-dispatched models) the registration is simply skipped Signed-off-by: supermario_leo <leo.stack@outlook.com> * refactor(watchdog): use EstimateModel with HF fallback for size estimation Switch estimateModelSizeBytes from calling vram.Estimate directly to the unified vram.EstimateModel entry point, which adds automatic fallbacks: file-based GGUF metadata → HF API → size string. Also extract the HuggingFace repo ID from model URIs (huggingface://, hf://, https://huggingface.co/ and org/model short-form) and pass it as ModelEstimateInput.HFRepo, so models not yet downloaded locally can still get a size estimate via the HF API. Addresses @mudler's review feedback: "better to rely on EstimateModel and pass by the HF URL of the model extracted from the URI". Signed-off-by: supermario_leo <leo.stack@outlook.com> * feat(webui): add Size-Aware Eviction toggle to settings page The size-aware eviction setting was wired through the CLI flag and the RuntimeSettings live-reload path (POST /api/settings) but had no handle on the React settings page, so it could not be toggled from the UI. Add a Size-Aware Eviction toggle to the Watchdog section, next to the existing Force Eviction When Busy / LRU eviction handles. The settings page loads and saves the whole RuntimeSettings object, so the new size_aware_eviction key is picked up with no extra plumbing. Addresses @mudler's review feedback: the application config setting should land on the same UI settings page as the other handles. Signed-off-by: supermario_leo <leo.stack@outlook.com> --------- Signed-off-by: supermario_leo <leo.stack@outlook.com>
1 parent b4c0dc6 commit b50b1fe

12 files changed

Lines changed: 319 additions & 22 deletions

File tree

core/application/startup.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,12 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
644644
options.ForceEvictionWhenBusy = *settings.ForceEvictionWhenBusy
645645
}
646646
}
647+
if settings.SizeAwareEviction != nil {
648+
// Only apply if current value is default (false), suggesting it wasn't set from env var
649+
if !options.SizeAwareEviction {
650+
options.SizeAwareEviction = *settings.SizeAwareEviction
651+
}
652+
}
647653
if settings.LRUEvictionMaxRetries != nil {
648654
// Only apply if current value is default (30), suggesting it wasn't set from env var
649655
if options.LRUEvictionMaxRetries == 0 {
@@ -847,6 +853,7 @@ func initializeWatchdog(application *Application, options *config.ApplicationCon
847853
model.WithLRULimit(lruLimit),
848854
model.WithMemoryReclaimer(options.MemoryReclaimerEnabled, options.MemoryReclaimerThreshold),
849855
model.WithForceEvictionWhenBusy(options.ForceEvictionWhenBusy),
856+
model.WithSizeAwareEviction(options.SizeAwareEviction),
850857
)
851858
application.ModelLoader().SetWatchDog(wd)
852859

core/application/watchdog.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ func (a *Application) startWatchdog() error {
9090
model.WithLRULimit(lruLimit),
9191
model.WithMemoryReclaimer(appConfig.MemoryReclaimerEnabled, appConfig.MemoryReclaimerThreshold),
9292
model.WithForceEvictionWhenBusy(appConfig.ForceEvictionWhenBusy),
93+
model.WithSizeAwareEviction(appConfig.SizeAwareEviction),
9394
)
9495

9596
// Create new stop channel BEFORE setting up any goroutines

core/backend/options.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package backend
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"math/rand/v2"
@@ -12,7 +13,9 @@ import (
1213
"github.com/mudler/LocalAI/core/config"
1314
"github.com/mudler/LocalAI/core/trace"
1415
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
16+
"github.com/mudler/LocalAI/pkg/downloader"
1517
"github.com/mudler/LocalAI/pkg/model"
18+
"github.com/mudler/LocalAI/pkg/vram"
1619
"github.com/mudler/xlog"
1720
)
1821

@@ -33,6 +36,67 @@ func recordModelLoadFailure(appConfig *config.ApplicationConfig, modelName, back
3336
})
3437
}
3538

39+
// estimateModelSizeBytes uses the unified EstimateModel entry point to compute
40+
// the total weight-file size for a model config. It collects all weight files
41+
// from DownloadFiles, Model, and MMProj, and also extracts the HuggingFace
42+
// repo ID so EstimateModel can fall back to the HF API when local file
43+
// metadata is unavailable (e.g. not-yet-downloaded models).
44+
func estimateModelSizeBytes(c config.ModelConfig, modelsPath string) int64 {
45+
seen := make(map[string]bool)
46+
input := vram.ModelEstimateInput{}
47+
48+
addFile := func(uri string) {
49+
if !vram.IsWeightFile(uri) {
50+
return
51+
}
52+
resolved := uri
53+
if !strings.Contains(uri, "://") {
54+
resolved = "file://" + filepath.Join(modelsPath, uri)
55+
}
56+
if seen[resolved] {
57+
return
58+
}
59+
seen[resolved] = true
60+
input.Files = append(input.Files, vram.FileInput{URI: resolved})
61+
}
62+
63+
// tryHFRepo resolves any huggingface:// or hf:// URI to an HTTPS URL and
64+
// then extracts the org/model repo ID for use as the HF fallback path.
65+
tryHFRepo := func(uri string) {
66+
if input.HFRepo != "" {
67+
return
68+
}
69+
resolved := downloader.URI(uri).ResolveURL()
70+
if repoID, ok := vram.ExtractHFRepoID(resolved); ok {
71+
input.HFRepo = repoID
72+
}
73+
}
74+
75+
for _, f := range c.DownloadFiles {
76+
uriStr := string(f.URI)
77+
addFile(uriStr)
78+
tryHFRepo(uriStr)
79+
}
80+
addFile(c.Model)
81+
tryHFRepo(c.Model)
82+
if c.MMProj != "" {
83+
addFile(c.MMProj)
84+
}
85+
86+
if len(input.Files) == 0 && input.HFRepo == "" {
87+
return 0
88+
}
89+
90+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
91+
defer cancel()
92+
93+
result, err := vram.EstimateModel(ctx, input)
94+
if err != nil || result.SizeBytes == 0 {
95+
return 0
96+
}
97+
return int64(result.SizeBytes)
98+
}
99+
36100
func ModelOptions(c config.ModelConfig, so *config.ApplicationConfig, opts ...model.Option) []model.Option {
37101
defOpts := []model.Option{
38102
model.WithBackendString(c.Backend),
@@ -70,6 +134,10 @@ func ModelOptions(c config.ModelConfig, so *config.ApplicationConfig, opts ...mo
70134
defOpts = append(defOpts, model.WithExternalBackend(k, v))
71135
}
72136

137+
if sizeBytes := estimateModelSizeBytes(c, so.SystemState.Model.ModelsPath); sizeBytes > 0 {
138+
defOpts = append(defOpts, model.WithModelSizeBytes(sizeBytes))
139+
}
140+
73141
return append(defOpts, opts...)
74142
}
75143

core/cli/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ type RunCMD struct {
9393
EnableMemoryReclaimer bool `env:"LOCALAI_MEMORY_RECLAIMER,MEMORY_RECLAIMER,LOCALAI_GPU_RECLAIMER,GPU_RECLAIMER" default:"false" help:"Enable memory threshold monitoring to auto-evict backends when memory usage exceeds threshold (uses GPU VRAM if available, otherwise RAM)" group:"backends"`
9494
MemoryReclaimerThreshold float64 `env:"LOCALAI_MEMORY_RECLAIMER_THRESHOLD,MEMORY_RECLAIMER_THRESHOLD,LOCALAI_GPU_RECLAIMER_THRESHOLD,GPU_RECLAIMER_THRESHOLD" default:"0.95" help:"Memory usage threshold (0.0-1.0) that triggers backend eviction (default 0.95 = 95%%)" group:"backends"`
9595
ForceEvictionWhenBusy bool `env:"LOCALAI_FORCE_EVICTION_WHEN_BUSY,FORCE_EVICTION_WHEN_BUSY" default:"false" help:"Force eviction even when models have active API calls (default: false for safety)" group:"backends"`
96+
SizeAwareEviction bool `env:"LOCALAI_SIZE_AWARE_EVICTION,SIZE_AWARE_EVICTION" default:"false" help:"Evict the largest loaded model first rather than the least-recently-used one, keeping small utility models resident and maximizing freed memory per eviction" group:"backends"`
9697
LRUEvictionMaxRetries int `env:"LOCALAI_LRU_EVICTION_MAX_RETRIES,LRU_EVICTION_MAX_RETRIES" default:"30" help:"Maximum number of retries when waiting for busy models to become idle before eviction (default: 30)" group:"backends"`
9798
LRUEvictionRetryInterval string `env:"LOCALAI_LRU_EVICTION_RETRY_INTERVAL,LRU_EVICTION_RETRY_INTERVAL" default:"1s" help:"Interval between retries when waiting for busy models to become idle (e.g., 1s, 2s) (default: 1s)" group:"backends"`
9899
Federated bool `env:"LOCALAI_FEDERATED,FEDERATED" help:"Enable federated instance" group:"federated"`
@@ -564,6 +565,9 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
564565
if r.ForceEvictionWhenBusy {
565566
opts = append(opts, config.WithForceEvictionWhenBusy(true))
566567
}
568+
if r.SizeAwareEviction {
569+
opts = append(opts, config.WithSizeAwareEviction(true))
570+
}
567571
if r.LRUEvictionMaxRetries > 0 {
568572
opts = append(opts, config.WithLRUEvictionMaxRetries(r.LRUEvictionMaxRetries))
569573
}

core/config/application_config.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ type ApplicationConfig struct {
119119

120120
// Eviction settings
121121
ForceEvictionWhenBusy bool // Force eviction even when models have active API calls (default: false for safety)
122+
SizeAwareEviction bool // Evict largest models first rather than least-recently-used (default: false)
122123
LRUEvictionMaxRetries int // Maximum number of retries when waiting for busy models to become idle (default: 30)
123124
LRUEvictionRetryInterval time.Duration // Interval between retries when waiting for busy models (default: 1s)
124125

@@ -488,6 +489,16 @@ func WithForceEvictionWhenBusy(enabled bool) AppOption {
488489
}
489490
}
490491

492+
// WithSizeAwareEviction enables size-aware eviction ordering.
493+
// When true, the watchdog evicts the largest loaded model first rather than the
494+
// least-recently-used one, keeping small utility models resident and maximizing
495+
// memory freed per eviction.
496+
func WithSizeAwareEviction(enabled bool) AppOption {
497+
return func(o *ApplicationConfig) {
498+
o.SizeAwareEviction = enabled
499+
}
500+
}
501+
491502
// WithLRUEvictionMaxRetries sets the maximum number of retries when waiting for busy models to become idle
492503
func WithLRUEvictionMaxRetries(maxRetries int) AppOption {
493504
return func(o *ApplicationConfig) {
@@ -1028,6 +1039,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
10281039
memoryReclaimerEnabled := o.MemoryReclaimerEnabled
10291040
memoryReclaimerThreshold := o.MemoryReclaimerThreshold
10301041
forceEvictionWhenBusy := o.ForceEvictionWhenBusy
1042+
sizeAwareEviction := o.SizeAwareEviction
10311043
lruEvictionMaxRetries := o.LRUEvictionMaxRetries
10321044
threads := o.Threads
10331045
contextSize := o.ContextSize
@@ -1120,6 +1132,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
11201132
MemoryReclaimerEnabled: &memoryReclaimerEnabled,
11211133
MemoryReclaimerThreshold: &memoryReclaimerThreshold,
11221134
ForceEvictionWhenBusy: &forceEvictionWhenBusy,
1135+
SizeAwareEviction: &sizeAwareEviction,
11231136
LRUEvictionMaxRetries: &lruEvictionMaxRetries,
11241137
LRUEvictionRetryInterval: &lruEvictionRetryInterval,
11251138
Threads: &threads,
@@ -1244,6 +1257,10 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
12441257
o.ForceEvictionWhenBusy = *settings.ForceEvictionWhenBusy
12451258
// This setting doesn't require restart, can be updated dynamically
12461259
}
1260+
if settings.SizeAwareEviction != nil {
1261+
o.SizeAwareEviction = *settings.SizeAwareEviction
1262+
// This setting doesn't require restart, can be updated dynamically
1263+
}
12471264
if settings.LRUEvictionMaxRetries != nil {
12481265
o.LRUEvictionMaxRetries = *settings.LRUEvictionMaxRetries
12491266
// This setting doesn't require restart, can be updated dynamically

core/config/runtime_settings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type RuntimeSettings struct {
2828

2929
// Eviction settings
3030
ForceEvictionWhenBusy *bool `json:"force_eviction_when_busy,omitempty"` // Force eviction even when models have active API calls (default: false for safety)
31+
SizeAwareEviction *bool `json:"size_aware_eviction,omitempty"` // Evict largest models first rather than least-recently-used (default: false)
3132
LRUEvictionMaxRetries *int `json:"lru_eviction_max_retries,omitempty"` // Maximum number of retries when waiting for busy models to become idle (default: 30)
3233
LRUEvictionRetryInterval *string `json:"lru_eviction_retry_interval,omitempty"` // Interval between retries when waiting for busy models (e.g., 1s, 2s) (default: 1s)
3334

core/http/react-ui/src/pages/Settings.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ export default function Settings() {
316316
<SettingRow label="Force Eviction When Busy" description="Allow model eviction even during active API calls">
317317
<Toggle checked={settings.force_eviction_when_busy} onChange={(v) => update('force_eviction_when_busy', v)} />
318318
</SettingRow>
319+
<SettingRow label="Size-Aware Eviction" description="Evict the largest loaded model first instead of the least-recently-used one">
320+
<Toggle checked={settings.size_aware_eviction} onChange={(v) => update('size_aware_eviction', v)} />
321+
</SettingRow>
319322
<SettingRow label="LRU Eviction Max Retries" description="Maximum retries waiting for busy models before eviction">
320323
<input className="input" type="number" style={{ width: 120 }} value={settings.lru_eviction_max_retries ?? ''} onChange={(e) => update('lru_eviction_max_retries', parseInt(e.target.value) || 0)} placeholder="30" />
321324
</SettingRow>

pkg/model/initializers.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,12 @@ func (ml *ModelLoader) grpcModel(backend string, o *Options) func(string, string
159159
return nil, fmt.Errorf("could not load model (no success): %s", res.Message)
160160
}
161161

162+
// Register size for size-aware eviction using the caller-supplied estimate
163+
// (computed via pkg/vram, which handles multi-file and non-GGUF models).
164+
if ml.wd != nil && o.modelSizeBytes > 0 {
165+
ml.wd.RegisterModelSize(modelID, o.modelSizeBytes)
166+
}
167+
162168
return client, nil
163169
}
164170
}

pkg/model/loader_options.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ type Options struct {
1919
grpcAttempts int
2020
grpcAttemptsDelay int
2121
parallelRequests bool
22+
23+
// modelSizeBytes is the estimated total weight size in bytes, pre-computed
24+
// by the caller using the vram estimation scaffolding. When non-zero it is
25+
// registered with the watchdog so size-aware eviction can rank models.
26+
modelSizeBytes int64
2227
}
2328

2429
type Option func(*Options)
@@ -86,6 +91,12 @@ func WithModelID(id string) Option {
8691
}
8792
}
8893

94+
func WithModelSizeBytes(bytes int64) Option {
95+
return func(o *Options) {
96+
o.modelSizeBytes = bytes
97+
}
98+
}
99+
89100
func NewOptions(opts ...Option) *Options {
90101
o := &Options{
91102
gRPCOptions: &pb.ModelOptions{},

0 commit comments

Comments
 (0)