Skip to content

Commit bbcaebc

Browse files
authored
feat(concurrency-groups): per-model exclusive groups for backend loading (#9662)
* feat(concurrency-groups): per-model exclusive groups for backend loading Adds `concurrency_groups: [...]` to model YAML configs. Two models that share a group cannot be loaded concurrently on the same node — loading one evicts the others, reusing the existing pinned/busy/retry policy from LRU eviction. Layered design: - Watchdog (pkg/model): per-node correctness floor — on every Load(), evict any loaded model that shares a group with the requested one. Pinned skips surface NeedMore so the loader retries (and ultimately logs a clear warning), instead of silently allowing the rule to be violated. - Distributed scheduler (core/services/nodes): soft anti-affinity hint — scheduleNewModel prefers nodes that don't already host a same-group model, falling back to eviction only if every candidate has a conflict. Composes with NodeSelector at the same point in the candidate pipeline. Per-node, not cluster-wide: VRAM is a node-local resource, and two heavy models running on different nodes is fine. The ConfigLoader is wired into SmartRouter via a small ConcurrencyConflictResolver interface so the nodes package keeps a narrow surface on core/config. Refactors the inner LRU eviction body into a shared collectEvictionsLocked helper and the loader retry loop into retryEnforce(fn, maxRetries, interval), so both LRU and group enforcement share busy/pinned/retry semantics. Closes #9659. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(watchdog): sync pinned + concurrency_groups at startup The startup-time watchdog setup lives in initializeWatchdog (startup.go), not in startWatchdog (watchdog.go). The latter is only invoked from the runtime-settings RestartWatchdog path. As a result, neither SyncPinnedModelsToWatchdog nor SyncModelGroupsToWatchdog ran at boot, so `pinned: true` and `concurrency_groups: [...]` only became effective after a settings-driven watchdog restart. Fix by adding both sync calls to initializeWatchdog. Confirmed end-to-end: loading model A in group "heavy", then C with no group (coexists), then B in group "heavy" now correctly evicts A and leaves [B, C]. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(test): satisfy errcheck on new os.Remove in concurrency_groups spec CI lint runs new-from-merge-base, so the existing pre-existing `defer os.Remove(tmp.Name())` lines are baseline-grandfathered but the one introduced by the concurrency_groups YAML round-trip test is held to errcheck. Wrap the remove in a closure that discards the error. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 22ae415 commit bbcaebc

17 files changed

Lines changed: 978 additions & 73 deletions

core/application/distributed.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ func (ds *DistributedServices) Shutdown() {
7171
// initDistributed validates distributed mode prerequisites and initializes
7272
// NATS, object storage, node registry, and instance identity.
7373
// Returns nil if distributed mode is not enabled.
74-
func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*DistributedServices, error) {
74+
// configLoader is used by the SmartRouter to compute concurrency-group
75+
// anti-affinity at placement time (#9659); it may be nil in tests.
76+
func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoader *config.ModelConfigLoader) (*DistributedServices, error) {
7577
if !cfg.Distributed.Enabled {
7678
return nil, nil
7779
}
@@ -234,12 +236,17 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
234236
remoteUnloader := nodes.NewRemoteUnloaderAdapter(registry, natsClient)
235237

236238
// All dependencies ready — build SmartRouter with all options at once
239+
var conflictResolver nodes.ConcurrencyConflictResolver
240+
if configLoader != nil {
241+
conflictResolver = configLoader
242+
}
237243
router := nodes.NewSmartRouter(registry, nodes.SmartRouterOptions{
238-
Unloader: remoteUnloader,
239-
FileStager: fileStager,
240-
GalleriesJSON: routerGalleriesJSON,
241-
AuthToken: routerAuthToken,
242-
DB: authDB,
244+
Unloader: remoteUnloader,
245+
FileStager: fileStager,
246+
GalleriesJSON: routerGalleriesJSON,
247+
AuthToken: routerAuthToken,
248+
DB: authDB,
249+
ConflictResolver: conflictResolver,
243250
})
244251

245252
// Create ReplicaReconciler for auto-scaling model replicas. Adapter +

core/application/startup.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func New(opts ...config.AppOption) (*Application, error) {
139139
}
140140

141141
// Initialize distributed mode services (NATS, object storage, node registry)
142-
distSvc, err := initDistributed(options, application.authDB)
142+
distSvc, err := initDistributed(options, application.authDB, application.ModelConfigLoader())
143143
if err != nil {
144144
return nil, fmt.Errorf("distributed mode initialization failed: %w", err)
145145
}
@@ -680,6 +680,12 @@ func initializeWatchdog(application *Application, options *config.ApplicationCon
680680
options.LRUEvictionRetryInterval,
681681
)
682682

683+
// Sync per-model state from configs to the watchdog. Without this,
684+
// `pinned: true` and `concurrency_groups:` are only honored after a
685+
// settings-driven RestartWatchdog and never at boot.
686+
application.SyncPinnedModelsToWatchdog()
687+
application.SyncModelGroupsToWatchdog()
688+
683689
// Start watchdog goroutine if any periodic checks are enabled
684690
// LRU eviction doesn't need the Run() loop - it's triggered on model load
685691
// But memory reclaimer needs the Run() loop for periodic checking

core/application/watchdog.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package application
22

33
import (
4+
"github.com/mudler/LocalAI/core/config"
45
"github.com/mudler/LocalAI/pkg/model"
56
"github.com/mudler/xlog"
67
)
@@ -26,6 +27,40 @@ func (a *Application) SyncPinnedModelsToWatchdog() {
2627
xlog.Debug("Synced pinned models to watchdog", "count", len(pinned))
2728
}
2829

30+
// SyncModelGroupsToWatchdog reads concurrency_groups from all model configs and
31+
// updates the watchdog so EnforceGroupExclusivity has the current view.
32+
func (a *Application) SyncModelGroupsToWatchdog() {
33+
cl := a.ModelConfigLoader()
34+
if cl == nil {
35+
return
36+
}
37+
wd := a.modelLoader.GetWatchDog()
38+
if wd == nil {
39+
return
40+
}
41+
groups := extractModelGroupsFromConfigs(cl.GetAllModelsConfigs())
42+
wd.ReplaceModelGroups(groups)
43+
xlog.Debug("Synced concurrency groups to watchdog", "count", len(groups))
44+
}
45+
46+
// extractModelGroupsFromConfigs builds the model→groups map the watchdog
47+
// expects. Disabled models are skipped — their declared groups should not
48+
// block other models from loading.
49+
func extractModelGroupsFromConfigs(configs []config.ModelConfig) map[string][]string {
50+
out := make(map[string][]string)
51+
for _, cfg := range configs {
52+
if cfg.IsDisabled() {
53+
continue
54+
}
55+
gs := cfg.GetConcurrencyGroups()
56+
if len(gs) == 0 {
57+
continue
58+
}
59+
out[cfg.Name] = gs
60+
}
61+
return out
62+
}
63+
2964
func (a *Application) StopWatchdog() error {
3065
if a.watchdogStop != nil {
3166
close(a.watchdogStop)
@@ -65,8 +100,9 @@ func (a *Application) startWatchdog() error {
65100
// Set the watchdog on the model loader
66101
a.modelLoader.SetWatchDog(wd)
67102

68-
// Sync pinned models from config to the watchdog
103+
// Sync pinned models and concurrency groups from config to the watchdog
69104
a.SyncPinnedModelsToWatchdog()
105+
a.SyncModelGroupsToWatchdog()
70106

71107
// Start watchdog goroutine if any periodic checks are enabled
72108
// LRU eviction doesn't need the Run() loop - it's triggered on model load
@@ -148,8 +184,9 @@ func (a *Application) RestartWatchdog() error {
148184
newWD.RestoreState(oldState)
149185
}
150186

151-
// Re-sync pinned models after restart
187+
// Re-sync pinned models and concurrency groups after restart
152188
a.SyncPinnedModelsToWatchdog()
189+
a.SyncModelGroupsToWatchdog()
153190

154191
return nil
155192
}

core/application/watchdog_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package application
2+
3+
import (
4+
"github.com/mudler/LocalAI/core/config"
5+
. "github.com/onsi/ginkgo/v2"
6+
. "github.com/onsi/gomega"
7+
)
8+
9+
var _ = Describe("extractModelGroupsFromConfigs", func() {
10+
It("returns an empty map when no config declares groups", func() {
11+
out := extractModelGroupsFromConfigs([]config.ModelConfig{
12+
{Name: "a"},
13+
{Name: "b"},
14+
})
15+
Expect(out).To(BeEmpty())
16+
})
17+
18+
It("returns each model's normalized groups", func() {
19+
out := extractModelGroupsFromConfigs([]config.ModelConfig{
20+
{Name: "a", ConcurrencyGroups: []string{" heavy ", "vision", "heavy"}},
21+
{Name: "b", ConcurrencyGroups: []string{"heavy"}},
22+
{Name: "c"}, // no groups → omitted
23+
})
24+
Expect(out).To(HaveLen(2))
25+
Expect(out["a"]).To(Equal([]string{"heavy", "vision"}))
26+
Expect(out["b"]).To(Equal([]string{"heavy"}))
27+
Expect(out).ToNot(HaveKey("c"))
28+
})
29+
30+
It("omits models whose groups normalize to empty", func() {
31+
out := extractModelGroupsFromConfigs([]config.ModelConfig{
32+
{Name: "blanks", ConcurrencyGroups: []string{"", " "}},
33+
})
34+
Expect(out).To(BeEmpty())
35+
})
36+
37+
It("skips disabled models so they cannot block loading after re-enable", func() {
38+
disabled := true
39+
out := extractModelGroupsFromConfigs([]config.ModelConfig{
40+
{Name: "a", ConcurrencyGroups: []string{"heavy"}, Disabled: &disabled},
41+
{Name: "b", ConcurrencyGroups: []string{"heavy"}},
42+
})
43+
Expect(out).To(HaveLen(1))
44+
Expect(out).To(HaveKey("b"))
45+
Expect(out).ToNot(HaveKey("a"))
46+
})
47+
})

core/config/model_config.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ type ModelConfig struct {
8787
Disabled *bool `yaml:"disabled,omitempty" json:"disabled,omitempty"`
8888
Pinned *bool `yaml:"pinned,omitempty" json:"pinned,omitempty"`
8989

90+
// ConcurrencyGroups declares per-node mutual-exclusion groups: the model
91+
// cannot be loaded alongside another model that shares any group name.
92+
// See docs/content/advanced/vram-management.md for usage.
93+
ConcurrencyGroups []string `yaml:"concurrency_groups,omitempty" json:"concurrency_groups,omitempty"`
94+
9095
Options []string `yaml:"options,omitempty" json:"options,omitempty"`
9196
Overrides []string `yaml:"overrides,omitempty" json:"overrides,omitempty"`
9297

@@ -587,6 +592,28 @@ func (c *ModelConfig) IsPinned() bool {
587592
return c.Pinned != nil && *c.Pinned
588593
}
589594

595+
// GetConcurrencyGroups returns the model's concurrency groups, normalized:
596+
// trimmed of whitespace, empty entries dropped, deduped. Returns nil when no
597+
// effective groups remain. The result is a fresh slice; the caller may
598+
// mutate it without affecting the config.
599+
func (c *ModelConfig) GetConcurrencyGroups() []string {
600+
if len(c.ConcurrencyGroups) == 0 {
601+
return nil
602+
}
603+
out := make([]string, 0, len(c.ConcurrencyGroups))
604+
for _, g := range c.ConcurrencyGroups {
605+
g = strings.TrimSpace(g)
606+
if g == "" || slices.Contains(out, g) {
607+
continue
608+
}
609+
out = append(out, g)
610+
}
611+
if len(out) == 0 {
612+
return nil
613+
}
614+
return out
615+
}
616+
590617
type ModelConfigUsecase int
591618

592619
const (

core/config/model_config_loader.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,40 @@ func (bcl *ModelConfigLoader) RemoveModelConfig(m string) {
249249
delete(bcl.configs, m)
250250
}
251251

252+
// GetModelsConflictingWith returns the names of every other configured (and
253+
// not-disabled) model that shares at least one concurrency group with the
254+
// named model. Returns nil if the named model has no groups, is unknown, or
255+
// has no peers in any of its groups. The result excludes the queried name.
256+
func (bcl *ModelConfigLoader) GetModelsConflictingWith(name string) []string {
257+
bcl.Lock()
258+
defer bcl.Unlock()
259+
target, ok := bcl.configs[name]
260+
if !ok {
261+
return nil
262+
}
263+
targetGroups := target.GetConcurrencyGroups()
264+
if len(targetGroups) == 0 {
265+
return nil
266+
}
267+
var conflicts []string
268+
for n, cfg := range bcl.configs {
269+
if n == name || cfg.IsDisabled() {
270+
continue
271+
}
272+
other := cfg.GetConcurrencyGroups()
273+
if len(other) == 0 {
274+
continue
275+
}
276+
for _, g := range targetGroups {
277+
if slices.Contains(other, g) {
278+
conflicts = append(conflicts, n)
279+
break
280+
}
281+
}
282+
}
283+
return conflicts
284+
}
285+
252286
// UpdateModelConfig updates an existing model config in the loader.
253287
// This is useful for updating runtime-detected properties like thinking support.
254288
func (bcl *ModelConfigLoader) UpdateModelConfig(m string, updater func(*ModelConfig)) {
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package config
2+
3+
import (
4+
. "github.com/onsi/ginkgo/v2"
5+
. "github.com/onsi/gomega"
6+
)
7+
8+
var _ = Describe("ModelConfigLoader.GetModelsConflictingWith", func() {
9+
var bcl *ModelConfigLoader
10+
11+
BeforeEach(func() {
12+
bcl = NewModelConfigLoader("/tmp/conflict-test-models")
13+
})
14+
15+
insert := func(cfg ModelConfig) {
16+
bcl.Lock()
17+
bcl.configs[cfg.Name] = cfg
18+
bcl.Unlock()
19+
}
20+
21+
It("returns nil when the named model has no groups", func() {
22+
insert(ModelConfig{Name: "loner"})
23+
Expect(bcl.GetModelsConflictingWith("loner")).To(BeNil())
24+
})
25+
26+
It("returns nil when the named model is unknown", func() {
27+
Expect(bcl.GetModelsConflictingWith("ghost")).To(BeNil())
28+
})
29+
30+
It("returns nil when no other model shares a group", func() {
31+
insert(ModelConfig{Name: "a", ConcurrencyGroups: []string{"heavy"}})
32+
insert(ModelConfig{Name: "b", ConcurrencyGroups: []string{"vision"}})
33+
Expect(bcl.GetModelsConflictingWith("a")).To(BeNil())
34+
})
35+
36+
It("returns models that share at least one group", func() {
37+
insert(ModelConfig{Name: "a", ConcurrencyGroups: []string{"heavy"}})
38+
insert(ModelConfig{Name: "b", ConcurrencyGroups: []string{"heavy"}})
39+
insert(ModelConfig{Name: "c", ConcurrencyGroups: []string{"vision"}})
40+
insert(ModelConfig{Name: "d", ConcurrencyGroups: []string{"heavy", "vision"}})
41+
42+
conflicts := bcl.GetModelsConflictingWith("a")
43+
Expect(conflicts).To(ConsistOf("b", "d"))
44+
})
45+
46+
It("never lists the queried model itself", func() {
47+
insert(ModelConfig{Name: "self", ConcurrencyGroups: []string{"heavy"}})
48+
Expect(bcl.GetModelsConflictingWith("self")).To(BeNil())
49+
})
50+
51+
It("ignores disabled conflicting models", func() {
52+
disabled := true
53+
insert(ModelConfig{Name: "a", ConcurrencyGroups: []string{"heavy"}})
54+
insert(ModelConfig{Name: "b", ConcurrencyGroups: []string{"heavy"}, Disabled: &disabled})
55+
Expect(bcl.GetModelsConflictingWith("a")).To(BeNil())
56+
})
57+
58+
It("normalizes groups so whitespace and duplicates do not break overlap", func() {
59+
insert(ModelConfig{Name: "a", ConcurrencyGroups: []string{" heavy "}})
60+
insert(ModelConfig{Name: "b", ConcurrencyGroups: []string{"heavy", "heavy"}})
61+
Expect(bcl.GetModelsConflictingWith("a")).To(ConsistOf("b"))
62+
})
63+
})

core/config/model_config_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,4 +264,53 @@ mcp:
264264
Expect(err).To(BeNil())
265265
Expect(valid).To(BeTrue())
266266
})
267+
Context("ConcurrencyGroups", func() {
268+
It("returns nil when no groups are configured", func() {
269+
cfg := &ModelConfig{Name: "no-groups"}
270+
Expect(cfg.GetConcurrencyGroups()).To(BeNil())
271+
})
272+
It("returns nil when all entries are blank", func() {
273+
cfg := &ModelConfig{
274+
Name: "blanks",
275+
ConcurrencyGroups: []string{"", " ", "\t"},
276+
}
277+
Expect(cfg.GetConcurrencyGroups()).To(BeNil())
278+
})
279+
It("trims whitespace, drops empty entries, and dedupes", func() {
280+
cfg := &ModelConfig{
281+
Name: "messy",
282+
ConcurrencyGroups: []string{" vram-heavy ", "", "vram-heavy", "vision", " vision "},
283+
}
284+
Expect(cfg.GetConcurrencyGroups()).To(Equal([]string{"vram-heavy", "vision"}))
285+
})
286+
It("returns a defensive copy", func() {
287+
cfg := &ModelConfig{
288+
Name: "copy",
289+
ConcurrencyGroups: []string{"heavy"},
290+
}
291+
got := cfg.GetConcurrencyGroups()
292+
got[0] = "tampered"
293+
Expect(cfg.GetConcurrencyGroups()).To(Equal([]string{"heavy"}))
294+
})
295+
It("parses concurrency_groups from YAML", func() {
296+
tmp, err := os.CreateTemp("", "concgroups.yaml")
297+
Expect(err).To(BeNil())
298+
defer func() { _ = os.Remove(tmp.Name()) }()
299+
_, err = tmp.WriteString(
300+
`name: heavy-a
301+
backend: llama-cpp
302+
parameters:
303+
model: heavy-a.gguf
304+
concurrency_groups:
305+
- vram-heavy
306+
- "120b"
307+
`)
308+
Expect(err).ToNot(HaveOccurred())
309+
configs, err := readModelConfigsFromFile(tmp.Name())
310+
Expect(err).To(BeNil())
311+
Expect(configs).To(HaveLen(1))
312+
Expect(configs[0].ConcurrencyGroups).To(Equal([]string{"vram-heavy", "120b"}))
313+
Expect(configs[0].GetConcurrencyGroups()).To(Equal([]string{"vram-heavy", "120b"}))
314+
})
315+
})
267316
})

core/services/nodes/interfaces.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ type ModelRouter interface {
3535
FindIdleNodeFromSet(ctx context.Context, nodeIDs []string) (*BackendNode, error)
3636
FindLeastLoadedNodeFromSet(ctx context.Context, nodeIDs []string) (*BackendNode, error)
3737
GetNodeLabels(ctx context.Context, nodeID string) ([]NodeLabel, error)
38+
FindNodesWithModel(ctx context.Context, modelName string) ([]BackendNode, error)
39+
}
40+
41+
// ConcurrencyConflictResolver returns the names of configured models that
42+
// share at least one concurrency group with the given model. It is satisfied
43+
// by *config.ModelConfigLoader and lets the SmartRouter make group-aware
44+
// placement decisions without importing the config package's full surface.
45+
type ConcurrencyConflictResolver interface {
46+
GetModelsConflictingWith(modelName string) []string
3847
}
3948

4049
// NodeHealthStore is used by HealthMonitor for node status management.

0 commit comments

Comments
 (0)