Skip to content

Commit d7eb059

Browse files
committed
feat(gallery): select model variants by hardware fit, not authored order
Gallery entries could already carry a list of alternatives, but selection was an authored, ordered, first-match policy: every candidate declared a `capability` string and the VRAM floors had to descend in a hand-tuned order. That pushed hardware knowledge onto whoever edits the gallery and made ordering load-bearing, so a reordered list silently changed what users installed. None of it was necessary. SystemState.IsBackendCompatible already derives hardware support from a backend name alone: it knows MLX and metal are Darwin-only, CUDA is NVIDIA-only, ROCm AMD-only, SYCL Intel-only. Selection can read that instead of asking authors to restate it. Authoring is now just a list of names: - name: qwen3.6-27b min_memory: 4GiB variants: - model: qwen3.6-27b-mlx-8bit - model: qwen3.6-27b-gguf-q8 min_memory: 28GiB and all the intelligence moved into the selector. Given a host it drops the variants whose backend cannot run here, drops those whose known memory requirement exceeds what the host has, and takes the LARGEST of what is left, because a bigger footprint is a higher quality quantization of the same model. A variant of unknown size is kept, since nothing proves it does not fit, but it ranks last so a proven fit always beats a guess. An explicit pin still wins outright, and if nothing survives the entry installs its own payload: the base always installs, this never refuses. Available memory is VRAM when a GPU was detected and system RAM otherwise, read through xsysinfo so a cgroup limit is honored and a container gets its own limit rather than the node's RAM. Capability disappears entirely, from the types, the schema and the lint. VRAM and RAM collapse into one `min_memory`, because a model's footprint is roughly the same wherever it lives and one figure is compared against whichever applies. The lint rules about ordering, the capability vocabulary and floor relationships are deleted with the hazards they described; what remains is that every variant names an entry that exists and does not itself declare variants, plus that any memory figure actually parses. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 30b3072 commit d7eb059

15 files changed

Lines changed: 1113 additions & 1048 deletions

.github/ci/gallery_denormalize.go

Lines changed: 37 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Command gallery_denormalize fills the read-only denormalized fields on the
2-
// candidates of gallery model entries: backend, quantization, and
3-
// inferred_min_vram.
2+
// variants of gallery model entries: backend, quantization, and
3+
// inferred_min_memory.
44
//
5-
// It never modifies an authored min_vram. Authored values are authoritative,
5+
// It never modifies an authored min_memory. Authored values are authoritative,
66
// because a human who measured a real load knows more than a pre-download
77
// estimate does.
88
package main
@@ -51,20 +51,20 @@ func main() {
5151

5252
failures := 0
5353
for i := range entries {
54-
if !entries[i].HasCandidates() {
54+
if !entries[i].HasVariants() {
5555
continue
5656
}
57-
for j := range entries[i].Candidates {
58-
c := &entries[i].Candidates[j]
57+
for j := range entries[i].Variants {
58+
c := &entries[i].Variants[j]
5959

6060
target, ok := byName[c.Model]
6161
if !ok {
62-
fmt.Fprintf(os.Stderr, "%s: candidate %q not found\n", entries[i].Name, c.Model)
62+
fmt.Fprintf(os.Stderr, "%s: variant %q not found\n", entries[i].Name, c.Model)
6363
failures++
6464
continue
6565
}
6666

67-
// The concrete entry's payload usually lives behind its url
67+
// The referenced entry's payload usually lives behind its url
6868
// rather than inline, so fetch it. Metadata.Backend is populated
6969
// at load time by the running server, not by parsing the index,
7070
// so it is always empty here and cannot be copied.
@@ -78,18 +78,17 @@ func main() {
7878
c.Backend = backendOf(target, resolved)
7979
c.Quantization = quantizationOf(target)
8080

81-
// Authored values win, and the final unconstrained candidate is
82-
// deliberately floorless, so neither gets an inferred value.
83-
// Clearing first matters: a candidate that gained an authored
84-
// min_vram, or that became the last resort after a reorder, would
85-
// otherwise keep a stale inferred floor that makes
86-
// EffectiveMinVRAM report a constraint the entry no longer has.
87-
if c.MinVRAM != "" || j == len(entries[i].Candidates)-1 {
88-
c.InferredMinVRAM = ""
81+
// An authored value wins outright, so it gets no inferred
82+
// counterpart. Clearing first matters: a variant that gained an
83+
// authored min_memory would otherwise keep a stale inferred figure
84+
// that EffectiveMinMemory would go on reporting once the authored
85+
// one is removed again.
86+
if c.MinMemory != "" {
87+
c.InferredMinMemory = ""
8988
continue
9089
}
9190

92-
// Weight files can come from either side: a concrete index entry
91+
// Weight files can come from either side: a referenced index entry
9392
// usually lists them itself (files:, i.e. AdditionalFiles) while
9493
// the url it points at is only a base config, but some entries
9594
// invert that. Install time downloads both, so estimate over both.
@@ -107,38 +106,38 @@ func main() {
107106
}, []uint32{estimateContext})
108107
if err != nil || estimate.VRAMForContext(estimateContext) == 0 {
109108
fmt.Fprintf(os.Stderr,
110-
"%s: could not estimate min_vram for %q (%v); author one by hand\n",
109+
"%s: could not estimate min_memory for %q (%v); author one by hand\n",
111110
entries[i].Name, c.Model, err)
112111
failures++
113112
continue
114113
}
115-
c.InferredMinVRAM = fmt.Sprintf("%dMiB", estimate.VRAMForContext(estimateContext)/(1024*1024))
114+
c.InferredMinMemory = fmt.Sprintf("%dMiB", estimate.VRAMForContext(estimateContext)/(1024*1024))
116115
}
117116
}
118117

119-
if err := writeCandidates(path, data, entries); err != nil {
118+
if err := writeVariants(path, data, entries); err != nil {
120119
fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err)
121120
os.Exit(1)
122121
}
123122

124123
if failures > 0 {
125-
fmt.Fprintf(os.Stderr, "%d candidate(s) need a hand-authored min_vram\n", failures)
124+
fmt.Fprintf(os.Stderr, "%d variant(s) need a hand-authored min_memory\n", failures)
126125
os.Exit(1)
127126
}
128127
}
129128

130-
// writeCandidates writes back only the three derived keys on each candidate,
129+
// writeVariants writes back only the three derived keys on each variant,
131130
// leaving every other byte of the index alone.
132131
//
133132
// Round-tripping through []GalleryModel would reflow all 1200+ entries
134133
// (quoting, key order, line wrapping), burying the handful of real changes in
135134
// reformatting noise and making the nightly PR unreviewable. Even re-encoding
136-
// just the candidates subtree would restyle authored fields such as min_vram,
135+
// just the variants subtree would restyle authored fields such as min_memory,
137136
// so the rewrite reaches down to individual mapping keys and the node tree is
138137
// re-encoded at the index's authored indent. That also makes the job
139138
// idempotent: a run that computes the same values leaves the file untouched
140139
// and opens no PR.
141-
func writeCandidates(path string, data []byte, entries []gallery.GalleryModel) error {
140+
func writeVariants(path string, data []byte, entries []gallery.GalleryModel) error {
142141
var doc yaml.Node
143142
if err := yaml.Unmarshal(data, &doc); err != nil {
144143
return fmt.Errorf("re-parse for node rewrite: %w", err)
@@ -156,30 +155,30 @@ func writeCandidates(path string, data []byte, entries []gallery.GalleryModel) e
156155

157156
changed := false
158157
for i, entryNode := range root.Content {
159-
if !entries[i].HasCandidates() || entryNode.Kind != yaml.MappingNode {
158+
if !entries[i].HasVariants() || entryNode.Kind != yaml.MappingNode {
160159
continue
161160
}
162161

163-
candidatesNode := mappingValue(entryNode, "candidates")
164-
if candidatesNode == nil || candidatesNode.Kind != yaml.SequenceNode {
165-
return fmt.Errorf("entry %q has candidates but no candidates sequence in the document", entries[i].Name)
162+
variantsNode := mappingValue(entryNode, "variants")
163+
if variantsNode == nil || variantsNode.Kind != yaml.SequenceNode {
164+
return fmt.Errorf("entry %q has variants but no variants sequence in the document", entries[i].Name)
166165
}
167-
if len(candidatesNode.Content) != len(entries[i].Candidates) {
168-
return fmt.Errorf("entry %q candidate count drift: %d nodes vs %d parsed",
169-
entries[i].Name, len(candidatesNode.Content), len(entries[i].Candidates))
166+
if len(variantsNode.Content) != len(entries[i].Variants) {
167+
return fmt.Errorf("entry %q variant count drift: %d nodes vs %d parsed",
168+
entries[i].Name, len(variantsNode.Content), len(entries[i].Variants))
170169
}
171170

172-
for j, candidateNode := range candidatesNode.Content {
173-
if candidateNode.Kind != yaml.MappingNode {
174-
return fmt.Errorf("entry %q candidate %d is not a mapping", entries[i].Name, j)
171+
for j, variantNode := range variantsNode.Content {
172+
if variantNode.Kind != yaml.MappingNode {
173+
return fmt.Errorf("entry %q variant %d is not a mapping", entries[i].Name, j)
175174
}
176-
c := entries[i].Candidates[j]
175+
c := entries[i].Variants[j]
177176
for _, kv := range []struct{ key, value string }{
178177
{"backend", c.Backend},
179178
{"quantization", c.Quantization},
180-
{"inferred_min_vram", c.InferredMinVRAM},
179+
{"inferred_min_memory", c.InferredMinMemory},
181180
} {
182-
if setMappingValue(candidateNode, kv.key, kv.value) {
181+
if setMappingValue(variantNode, kv.key, kv.value) {
183182
changed = true
184183
}
185184
}
@@ -287,7 +286,7 @@ func backendOf(m gallery.GalleryModel, resolved gallery.ModelConfig) string {
287286
return ""
288287
}
289288

290-
// quantizationOf reports the quantization declared by a concrete entry's
289+
// quantizationOf reports the quantization declared by a referenced entry's
291290
// overrides, empty when it declares none.
292291
func quantizationOf(m gallery.GalleryModel) string {
293292
if q, ok := m.Overrides["quantization"].(string); ok {

core/gallery/candidate.go

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)