Skip to content

Commit ed3b59b

Browse files
localai-botmudler
andauthored
fix(config): cap auto-derived context to fit VRAM (#10696)
When a model is imported without an explicit context_size, the GGUF importer defaulted the model's context to its full trained window (n_ctx_train). For long-context models (128k / 256k / 1M) that KV cache cannot fit a consumer GPU, so the backend aborts on load (exitCode=-1) even though the model file is perfectly fine. Reproduced live: gemma-4-26b-a4b-it-qat-q4_0 defaulted to context=262144 and qwythos-9b-claude-mythos-5-1m to 1048576, both aborting on a 20 GB card. Instead of chasing the trained max, auto-derive a conservative default: min(trainedMax, DefaultAutoContextSize=8192). A small model keeps its trained window; a long-context model caps at 8k and users opt into more via context_size. This cap applies always, including CPU / unknown-VRAM hosts, so it never regresses those paths. Per-device VRAM is used only as a DOWNWARD safety: when a per-device ceiling is detected (xsysinfo.MinPerGPUVRAM) and even the 8k cap would not fit it with headroom, step down through candidate contexts to the largest that fits, floored at DefaultContextSize. When VRAM is unknown (0) or no GPU is detected we do NOT clamp — the bug is GPU OOM and the 8k cap is already safe, so detection gaps must not shrink the window. The footprint estimate reuses gpustack/gguf-parser-go's EstimateLLaMACppRun at a given context with all layers offloaded, taking the per-device NonUMA VRAM figure. The estimate and VRAM detection are package vars so tests inject deterministic values. Explicit context_size always wins (guessGGUFFromFile only acts when it is nil). Assisted-by: Claude:claude-opus-4-8 [golangci-lint go-test] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 461ae84 commit ed3b59b

4 files changed

Lines changed: 265 additions & 3 deletions

File tree

core/config/context_fit.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package config
2+
3+
import (
4+
gguf "github.com/gpustack/gguf-parser-go"
5+
"github.com/mudler/LocalAI/pkg/xsysinfo"
6+
"github.com/mudler/xlog"
7+
)
8+
9+
// contextFitHeadroomDivisor reserves a slice of per-device VRAM as headroom when
10+
// deciding whether an auto-derived context fits. The gguf-parser footprint
11+
// already covers weights + KV + compute buffer, but a live load also pays for
12+
// allocator fragmentation, the CUDA/HIP context, and whatever else shares the
13+
// card, so we require the estimate to leave at least 1/divisor of the device
14+
// free. /5 (~20% headroom) mirrors the SWA full-cache gate's margin.
15+
const contextFitHeadroomDivisor = 5
16+
17+
// contextFitCandidates is the descending set of context windows tried when the
18+
// DefaultAutoContextSize cap itself does not fit per-device VRAM. Only the rare
19+
// big-model-on-tiny-card case reaches this walk; it is capped at the base
20+
// choice and floored at DefaultContextSize, and returns the first (largest)
21+
// candidate that fits.
22+
var contextFitCandidates = []int{8192, 6144, 4096}
23+
24+
// perDeviceVRAM reports the smallest per-GPU VRAM ceiling in bytes (0 = unknown
25+
// or no GPU). It is a package var so tests can inject a deterministic value —
26+
// detection does a live GPU probe. Per-device (not summed) is the right budget:
27+
// with all layers offloaded to a single device the whole footprint must fit that
28+
// one card, and a multi-GPU host is bounded by its smallest card. This mirrors
29+
// localGPU's use of MinPerGPUVRAM in hardware_defaults.go.
30+
var perDeviceVRAM = func() uint64 {
31+
v, _ := xsysinfo.MinPerGPUVRAM()
32+
return v
33+
}
34+
35+
// estimateContextVRAM returns the estimated per-device VRAM footprint (bytes) of
36+
// running f fully offloaded at ctx tokens — weights + KV cache + compute buffer.
37+
// It returns 0 when it cannot produce an estimate (nil file, no tensors, or a
38+
// parser panic), which the caller treats as "cannot confirm a smaller fit" and
39+
// so keeps the conservative cap rather than clamping on a bogus number. It is a
40+
// package var so tests can stub it (a fabricated GGUF carries no tensors and
41+
// estimates to ~0).
42+
var estimateContextVRAM = func(f *gguf.GGUFFile, ctx int) (footprint uint64) {
43+
if f == nil {
44+
return 0
45+
}
46+
if ctx <= 0 {
47+
ctx = DefaultContextSize
48+
}
49+
// The gguf-parser estimator panics on degenerate / partially-parsed GGUFs;
50+
// treat any failure as "unknown" so config loading never crashes on a model
51+
// the parser mis-handles.
52+
defer func() {
53+
if r := recover(); r != nil {
54+
xlog.Debug("[context_fit] per-device VRAM estimate failed; treating as unknown", "error", r)
55+
footprint = 0
56+
}
57+
}()
58+
// Offload all layers (LocalAI's DefaultNGPULayers default; the estimator
59+
// clamps to the model's block count) so the estimate reflects a fully
60+
// GPU-resident model. NonUMA is the discrete-GPU figure (larger than the UMA
61+
// one), which keeps the fit check conservative on unified-memory hosts — they
62+
// have ample memory to clear it anyway.
63+
est := f.EstimateLLaMACppRun(
64+
gguf.WithLLaMACppContextSize(int32(ctx)),
65+
gguf.WithLLaMACppOffloadLayers(uint64(DefaultNGPULayers)),
66+
)
67+
sum := est.Summarize(true, 0, 0)
68+
if len(sum.Items) == 0 {
69+
return 0
70+
}
71+
var total uint64
72+
for _, v := range sum.Items[0].VRAMs {
73+
total += uint64(v.NonUMA)
74+
}
75+
return total
76+
}
77+
78+
// contextFitsVRAM reports whether an estimated footprint fits a per-device VRAM
79+
// ceiling with headroom (VRAM must exceed the footprint by ~1/divisor). Unknown
80+
// inputs (0) are treated as "cannot confirm" so a detection or estimate gap does
81+
// not clamp the context.
82+
func contextFitsVRAM(footprint, vram uint64) bool {
83+
if footprint == 0 || vram == 0 {
84+
return false
85+
}
86+
return vram >= footprint+footprint/contextFitHeadroomDivisor
87+
}
88+
89+
// autoContextSize picks the default context to use for f when the user did not
90+
// set context_size. The choice is deliberately conservative, NOT
91+
// VRAM-maximizing:
92+
//
93+
// 1. Base cap: min(trainedMax, DefaultAutoContextSize). A small model keeps its
94+
// trained window; a long-context model (128k / 256k / 1M) is capped so its
95+
// KV cache does not default to a size no consumer GPU can hold. This applies
96+
// always, including CPU / unknown-VRAM hosts.
97+
// 2. VRAM is only a downward safety: when a per-device VRAM ceiling IS detected
98+
// and even the base cap would not fit it (with headroom), step down through
99+
// contextFitCandidates to the largest window that fits, floored at
100+
// DefaultContextSize. When VRAM is unknown we skip this — the base cap is
101+
// already safe and we must not regress CPU / detection-gap hosts.
102+
//
103+
// trainedMax <= 0 means the estimate yielded nothing usable; the caller keeps
104+
// its existing DefaultContextSize fallback in that case, so this is only called
105+
// with a positive trainedMax.
106+
func autoContextSize(f *gguf.GGUFFile, trainedMax int) int {
107+
chosen := trainedMax
108+
if chosen > DefaultAutoContextSize {
109+
chosen = DefaultAutoContextSize
110+
}
111+
112+
vram := perDeviceVRAM()
113+
if vram == 0 {
114+
// No per-device VRAM detected (CPU-only, unified memory reporting nothing,
115+
// or a detection gap). The bug is GPU OOM-on-load, so with no GPU budget to
116+
// reason about we must not clamp — the base cap already bounds long-context
117+
// models.
118+
return chosen
119+
}
120+
121+
if contextFitsVRAM(estimateContextVRAM(f, chosen), vram) {
122+
return chosen
123+
}
124+
125+
// The base cap does not fit this card. Walk candidates downward and take the
126+
// largest that fits, never below DefaultContextSize.
127+
for _, cand := range contextFitCandidates {
128+
if cand > chosen || cand < DefaultContextSize {
129+
continue
130+
}
131+
if contextFitsVRAM(estimateContextVRAM(f, cand), vram) {
132+
xlog.Debug("[context_fit] capped auto context to fit per-device VRAM",
133+
"context", cand, "base_cap", chosen, "vram_gib", vram>>30)
134+
return cand
135+
}
136+
}
137+
138+
// Nothing fit (an unusually large model on a tiny card): fall back to the
139+
// floor. The backend still clamps n_gpu_layers to what fits, so a partial
140+
// offload can keep the model loadable rather than aborting outright.
141+
xlog.Debug("[context_fit] no candidate context fit per-device VRAM; using floor",
142+
"context", DefaultContextSize, "base_cap", chosen, "vram_gib", vram>>30)
143+
return DefaultContextSize
144+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package config
2+
3+
import (
4+
gguf "github.com/gpustack/gguf-parser-go"
5+
. "github.com/onsi/ginkgo/v2"
6+
. "github.com/onsi/gomega"
7+
)
8+
9+
// These specs exercise the auto-derived default context. The detection seams
10+
// (perDeviceVRAM, estimateContextVRAM) are package vars so a deterministic VRAM
11+
// ceiling and footprint can be injected without a real GPU or model file — the
12+
// same pattern hardware_defaults_internal_test.go uses for localGPU.
13+
var _ = Describe("Auto-derived default context (VRAM-aware cap)", func() {
14+
const gib = uint64(1) << 30
15+
16+
var (
17+
origVRAM func() uint64
18+
origEstimate func(f *gguf.GGUFFile, ctx int) uint64
19+
)
20+
21+
BeforeEach(func() {
22+
origVRAM = perDeviceVRAM
23+
origEstimate = estimateContextVRAM
24+
})
25+
AfterEach(func() {
26+
perDeviceVRAM = origVRAM
27+
estimateContextVRAM = origEstimate
28+
})
29+
30+
Context("autoContextSize", func() {
31+
It("caps a long-context model at DefaultAutoContextSize when VRAM is ample", func() {
32+
// 1M-context model on an 80 GiB card: we do NOT chase the trained max,
33+
// we keep the conservative 8k cap (users opt into more via context_size).
34+
perDeviceVRAM = func() uint64 { return 80 * gib }
35+
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return gib } // trivially fits
36+
Expect(autoContextSize(nil, 1048576)).To(Equal(DefaultAutoContextSize))
37+
})
38+
39+
It("keeps a small model's trained window instead of inflating it", func() {
40+
// trained 4096 < 8192: min() keeps 4096, it is not raised to the cap.
41+
perDeviceVRAM = func() uint64 { return 80 * gib }
42+
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return gib }
43+
Expect(autoContextSize(nil, 4096)).To(Equal(4096))
44+
})
45+
46+
It("steps below the cap when even 8k would not fit a tiny card", func() {
47+
// A large model on a 2 GiB card where the 8k footprint overflows but a
48+
// smaller context fits: choose the largest that fits, never below the
49+
// floor. Footprint grows with context so the walk finds a fit.
50+
perDeviceVRAM = func() uint64 { return 2 * gib }
51+
estimateContextVRAM = func(_ *gguf.GGUFFile, ctx int) uint64 {
52+
return gib + uint64(ctx)*100000
53+
}
54+
chosen := autoContextSize(nil, 1048576)
55+
Expect(chosen).To(BeNumerically("<", DefaultAutoContextSize))
56+
Expect(chosen).To(BeNumerically(">=", DefaultContextSize))
57+
// The chosen context's footprint must actually fit the card with headroom.
58+
Expect(contextFitsVRAM(estimateContextVRAM(nil, chosen), 2*gib)).To(BeTrue())
59+
})
60+
61+
It("falls back to the floor when nothing fits", func() {
62+
// Even DefaultContextSize does not fit: return the floor and let the
63+
// backend clamp n_gpu_layers to what it can (partial offload) rather
64+
// than defaulting to a window guaranteed to abort.
65+
perDeviceVRAM = func() uint64 { return 1 * gib }
66+
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return 100 * gib }
67+
Expect(autoContextSize(nil, 1048576)).To(Equal(DefaultContextSize))
68+
})
69+
70+
It("does not clamp when per-device VRAM is unknown", func() {
71+
// CPU-only / detection gap: no GPU budget to reason about, so we must
72+
// not regress — keep the conservative base cap regardless of estimate.
73+
perDeviceVRAM = func() uint64 { return 0 }
74+
estimateContextVRAM = func(_ *gguf.GGUFFile, _ int) uint64 { return 999 * gib }
75+
Expect(autoContextSize(nil, 1048576)).To(Equal(DefaultAutoContextSize))
76+
})
77+
})
78+
79+
Context("guessGGUFFromFile", func() {
80+
It("never overrides an explicitly configured context_size", func() {
81+
// A fabricated GGUF is enough: the context branch is skipped entirely
82+
// when the user pinned context_size, so the estimate is never consulted.
83+
explicit := 262144
84+
cfg := &ModelConfig{LLMConfig: LLMConfig{ContextSize: &explicit}}
85+
f := &gguf.GGUFFile{
86+
Header: gguf.GGUFHeader{
87+
MetadataKV: gguf.GGUFMetadataKVs{
88+
{
89+
Key: "general.architecture",
90+
ValueType: gguf.GGUFMetadataValueTypeString,
91+
Value: "llama",
92+
},
93+
},
94+
},
95+
}
96+
guessGGUFFromFile(cfg, f, 0)
97+
Expect(cfg.ContextSize).ToNot(BeNil())
98+
Expect(*cfg.ContextSize).To(Equal(262144))
99+
})
100+
})
101+
})

core/config/defaults.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@ const (
1818
// safe default beats a tiny, surprising window that truncates real prompts.
1919
DefaultContextSize = 4096
2020

21+
// DefaultAutoContextSize caps the context we auto-derive from a GGUF when the
22+
// user did not set context_size. The GGUF importer used to default a model's
23+
// context to its full trained window (n_ctx_train). For long-context models
24+
// (128k / 256k / 1M) that KV cache cannot fit a consumer GPU and the backend
25+
// aborts on load (exitCode=-1) even though the model file is fine. So instead
26+
// of shooting for the trained max, we keep a modest default: a small model
27+
// (trained < this) keeps its trained window, while a long-context model caps
28+
// here. Users who want the full window raise context_size explicitly. This is
29+
// a conservative default, not a VRAM-maximizing one — VRAM is only used to
30+
// step further DOWN when even this cap would not fit (see context_fit.go).
31+
DefaultAutoContextSize = 8192
32+
2133
// DefaultNGPULayers means "offload all layers"; the backend (fit_params)
2234
// clamps to what actually fits in device memory.
2335
DefaultNGPULayers = 99999999

core/config/gguf.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,14 @@ func reservedNonChatModel(cfg *ModelConfig) bool {
2828

2929
func guessGGUFFromFile(cfg *ModelConfig, f *gguf.GGUFFile, defaultCtx int) {
3030
if defaultCtx == 0 && cfg.ContextSize == nil {
31-
ctxSize := f.EstimateLLaMACppRun().ContextSize
32-
if ctxSize > 0 {
33-
cSize := int(ctxSize)
31+
// trainedMax is the model's full trained context window (n_ctx_train).
32+
// Defaulting a model to it unbounded is what OOMs long-context models at
33+
// load: a 128k / 256k / 1M KV cache cannot fit a consumer GPU and the
34+
// backend aborts (exitCode=-1). autoContextSize instead caps to a modest
35+
// default and only steps below it when detected per-device VRAM demands.
36+
trainedMax := int(f.EstimateLLaMACppRun().ContextSize)
37+
if trainedMax > 0 {
38+
cSize := autoContextSize(f, trainedMax)
3439
cfg.ContextSize = &cSize
3540
} else {
3641
defaultCtx = DefaultContextSize

0 commit comments

Comments
 (0)