Skip to content

Commit fc5b9eb

Browse files
authored
feat(loader): enhance single active backend to support LRU eviction (#7535)
* feat(loader): refactor single active backend support to LRU This changeset introduces LRU management of loaded backends. Users can set now a maximum number of models to be loaded concurrently, and, when setting LocalAI in single active backend mode we set LRU to 1 for backward compatibility. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: add tests Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Update docs Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * Fixups Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent c141a40 commit fc5b9eb

39 files changed

Lines changed: 835 additions & 130 deletions

core/application/application.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type Application struct {
2929
func newApplication(appConfig *config.ApplicationConfig) *Application {
3030
return &Application{
3131
backendLoader: config.NewModelConfigLoader(appConfig.SystemState.Model.ModelsPath),
32-
modelLoader: model.NewModelLoader(appConfig.SystemState, appConfig.SingleBackend),
32+
modelLoader: model.NewModelLoader(appConfig.SystemState),
3333
applicationConfig: appConfig,
3434
templatesEvaluator: templates.NewEvaluator(appConfig.SystemState.Model.ModelsPath),
3535
}

core/application/config_file_watcher.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ type runtimeSettings struct {
191191
WatchdogBusyEnabled *bool `json:"watchdog_busy_enabled,omitempty"`
192192
WatchdogIdleTimeout *string `json:"watchdog_idle_timeout,omitempty"`
193193
WatchdogBusyTimeout *string `json:"watchdog_busy_timeout,omitempty"`
194-
SingleBackend *bool `json:"single_backend,omitempty"`
194+
SingleBackend *bool `json:"single_backend,omitempty"` // Deprecated: use MaxActiveBackends = 1 instead
195+
MaxActiveBackends *int `json:"max_active_backends,omitempty"` // Maximum number of active backends (0 = unlimited, 1 = single backend mode)
195196
ParallelBackendRequests *bool `json:"parallel_backend_requests,omitempty"`
196197
Threads *int `json:"threads,omitempty"`
197198
ContextSize *int `json:"context_size,omitempty"`
@@ -224,6 +225,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
224225
envWatchdogIdleTimeout := appConfig.WatchDogIdleTimeout == startupAppConfig.WatchDogIdleTimeout
225226
envWatchdogBusyTimeout := appConfig.WatchDogBusyTimeout == startupAppConfig.WatchDogBusyTimeout
226227
envSingleBackend := appConfig.SingleBackend == startupAppConfig.SingleBackend
228+
envMaxActiveBackends := appConfig.MaxActiveBackends == startupAppConfig.MaxActiveBackends
227229
envParallelRequests := appConfig.ParallelBackendRequests == startupAppConfig.ParallelBackendRequests
228230
envThreads := appConfig.Threads == startupAppConfig.Threads
229231
envContextSize := appConfig.ContextSize == startupAppConfig.ContextSize
@@ -275,8 +277,19 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
275277
log.Warn().Err(err).Str("timeout", *settings.WatchdogBusyTimeout).Msg("invalid watchdog busy timeout in runtime_settings.json")
276278
}
277279
}
278-
if settings.SingleBackend != nil && !envSingleBackend {
280+
// Handle MaxActiveBackends (new) and SingleBackend (deprecated)
281+
if settings.MaxActiveBackends != nil && !envMaxActiveBackends {
282+
appConfig.MaxActiveBackends = *settings.MaxActiveBackends
283+
// For backward compatibility, also set SingleBackend if MaxActiveBackends == 1
284+
appConfig.SingleBackend = (*settings.MaxActiveBackends == 1)
285+
} else if settings.SingleBackend != nil && !envSingleBackend {
286+
// Legacy: SingleBackend maps to MaxActiveBackends = 1
279287
appConfig.SingleBackend = *settings.SingleBackend
288+
if *settings.SingleBackend {
289+
appConfig.MaxActiveBackends = 1
290+
} else {
291+
appConfig.MaxActiveBackends = 0
292+
}
280293
}
281294
if settings.ParallelBackendRequests != nil && !envParallelRequests {
282295
appConfig.ParallelBackendRequests = *settings.ParallelBackendRequests

core/application/startup.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
224224
WatchdogBusyEnabled *bool `json:"watchdog_busy_enabled,omitempty"`
225225
WatchdogIdleTimeout *string `json:"watchdog_idle_timeout,omitempty"`
226226
WatchdogBusyTimeout *string `json:"watchdog_busy_timeout,omitempty"`
227-
SingleBackend *bool `json:"single_backend,omitempty"`
227+
SingleBackend *bool `json:"single_backend,omitempty"` // Deprecated: use MaxActiveBackends = 1 instead
228+
MaxActiveBackends *int `json:"max_active_backends,omitempty"` // Maximum number of active backends (0 = unlimited)
228229
ParallelBackendRequests *bool `json:"parallel_backend_requests,omitempty"`
229230
AgentJobRetentionDays *int `json:"agent_job_retention_days,omitempty"`
230231
}
@@ -280,9 +281,21 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
280281
}
281282
}
282283
}
283-
if settings.SingleBackend != nil {
284+
// Handle MaxActiveBackends (new) and SingleBackend (deprecated)
285+
if settings.MaxActiveBackends != nil {
286+
// Only apply if current value is default (0), suggesting it wasn't set from env var
287+
if options.MaxActiveBackends == 0 {
288+
options.MaxActiveBackends = *settings.MaxActiveBackends
289+
// For backward compatibility, also set SingleBackend if MaxActiveBackends == 1
290+
options.SingleBackend = (*settings.MaxActiveBackends == 1)
291+
}
292+
} else if settings.SingleBackend != nil {
293+
// Legacy: SingleBackend maps to MaxActiveBackends = 1
284294
if !options.SingleBackend {
285295
options.SingleBackend = *settings.SingleBackend
296+
if *settings.SingleBackend {
297+
options.MaxActiveBackends = 1
298+
}
286299
}
287300
}
288301
if settings.ParallelBackendRequests != nil {
@@ -307,15 +320,25 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
307320

308321
// initializeWatchdog initializes the watchdog with current ApplicationConfig settings
309322
func initializeWatchdog(application *Application, options *config.ApplicationConfig) {
310-
if options.WatchDog {
323+
// Get effective max active backends (considers both MaxActiveBackends and deprecated SingleBackend)
324+
lruLimit := options.GetEffectiveMaxActiveBackends()
325+
326+
// Create watchdog if enabled OR if LRU limit is set
327+
if options.WatchDog || lruLimit > 0 {
311328
wd := model.NewWatchDog(
312329
application.ModelLoader(),
313330
options.WatchDogBusyTimeout,
314331
options.WatchDogIdleTimeout,
315332
options.WatchDogBusy,
316-
options.WatchDogIdle)
333+
options.WatchDogIdle,
334+
lruLimit)
317335
application.ModelLoader().SetWatchDog(wd)
318-
go wd.Run()
336+
337+
// Start watchdog goroutine only if busy/idle checks are enabled
338+
if options.WatchDogBusy || options.WatchDogIdle {
339+
go wd.Run()
340+
}
341+
319342
go func() {
320343
<-options.Context.Done()
321344
log.Debug().Msgf("Context canceled, shutting down")

core/application/watchdog.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,29 @@ func (a *Application) StopWatchdog() error {
2020
func (a *Application) startWatchdog() error {
2121
appConfig := a.ApplicationConfig()
2222

23-
// Create new watchdog if enabled
24-
if appConfig.WatchDog {
23+
// Get effective max active backends (considers both MaxActiveBackends and deprecated SingleBackend)
24+
lruLimit := appConfig.GetEffectiveMaxActiveBackends()
25+
26+
// Create watchdog if enabled OR if LRU limit is set
27+
// LRU eviction requires watchdog infrastructure even without busy/idle checks
28+
if appConfig.WatchDog || lruLimit > 0 {
2529
wd := model.NewWatchDog(
2630
a.modelLoader,
2731
appConfig.WatchDogBusyTimeout,
2832
appConfig.WatchDogIdleTimeout,
2933
appConfig.WatchDogBusy,
30-
appConfig.WatchDogIdle)
34+
appConfig.WatchDogIdle,
35+
lruLimit)
3136
a.modelLoader.SetWatchDog(wd)
3237

3338
// Create new stop channel
3439
a.watchdogStop = make(chan bool, 1)
3540

36-
// Start watchdog goroutine
37-
go wd.Run()
41+
// Start watchdog goroutine only if busy/idle checks are enabled
42+
// LRU eviction doesn't need the Run() loop - it's triggered on model load
43+
if appConfig.WatchDogBusy || appConfig.WatchDogIdle {
44+
go wd.Run()
45+
}
3846

3947
// Setup shutdown handler
4048
go func() {
@@ -48,7 +56,7 @@ func (a *Application) startWatchdog() error {
4856
}
4957
}()
5058

51-
log.Info().Msg("Watchdog started with new settings")
59+
log.Info().Int("lruLimit", lruLimit).Bool("busyCheck", appConfig.WatchDogBusy).Bool("idleCheck", appConfig.WatchDogIdle).Msg("Watchdog started with new settings")
5260
} else {
5361
log.Info().Msg("Watchdog disabled")
5462
}

core/backend/detection.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ func Detection(
2020
if err != nil {
2121
return nil, err
2222
}
23-
defer loader.Close()
2423

2524
if detectionModel == nil {
2625
return nil, fmt.Errorf("could not load detection model")

core/backend/embeddings.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ func ModelEmbedding(s string, tokens []int, loader *model.ModelLoader, modelConf
1717
if err != nil {
1818
return nil, err
1919
}
20-
defer loader.Close()
2120

2221
var fn func() ([]float32, error)
2322
switch model := inferenceModel.(type) {

core/backend/image.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ func ImageGeneration(height, width, mode, step, seed int, positive_prompt, negat
1616
if err != nil {
1717
return nil, err
1818
}
19-
defer loader.Close()
2019

2120
fn := func() error {
2221
_, err := inferenceModel.GenerateImage(

core/backend/llm.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
6060
if err != nil {
6161
return nil, err
6262
}
63-
defer loader.Close()
6463

6564
var protoMessages []*proto.Message
6665
// if we are using the tokenizer template, we need to convert the messages to proto messages

core/backend/rerank.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ func Rerank(request *proto.RerankRequest, loader *model.ModelLoader, appConfig *
1515
if err != nil {
1616
return nil, err
1717
}
18-
defer loader.Close()
1918

2019
if rerankModel == nil {
2120
return nil, fmt.Errorf("could not load rerank model")

core/backend/soundgeneration.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ func SoundGeneration(
2929
if err != nil {
3030
return "", nil, err
3131
}
32-
defer loader.Close()
3332

3433
if soundGenModel == nil {
3534
return "", nil, fmt.Errorf("could not load sound generation model")

0 commit comments

Comments
 (0)