Skip to content

Commit 85f5267

Browse files
localai-botmudler
andauthored
fix(llama-cpp): cap single-pass embedding batch to fit VRAM (#10695)
* fix(llama-cpp): cap single-pass embedding batch to fit VRAM Embedding/score/rerank all decode or pool the whole input in one physical batch, so EffectiveBatchSize sized the batch to the full context window. For a large context that makes n_ubatch huge, and the per-device CUDA compute buffer (forward-graph scratch, ~n_ubatch * n_ctx, NOT split across GPUs) balloons into multi-GiB: a large-context embedding model then aborts on load (exitCode=-1) even with plenty of free VRAM. Reproduced with qwen3-embedding-4b (context 40960 -> n_batch 40960 -> abort) and qwen3-embedding-0.6b (n_batch 8192); pinning batch:512 avoided it. This is the same root cause as issue #10485 (a large context turns the batch into multi-GiB of scratch that must fit on a SINGLE card), but the single-pass path bypassed the VRAM headroom guard the config layer already had — it returned the unbounded context as the batch with no GPU awareness. Make the single-pass batch VRAM-aware: cap it to the largest batch whose compute buffer fits the per-device VRAM headroom, clamped to [DefaultPhysicalBatch, ctx], reusing the existing computeBufferBytesPerCell and headroom-divisor math (no duplication). Unknown per-device VRAM (0) stays conservative (DefaultPhysicalBatch, not the context) so a detection gap can't OOM. The GPU is resolved through an injectable package var (config.LocalGPU, backed by sync.Once-cached xsysinfo detection) so the per-request router call stays cheap and tests inject a deterministic device. Explicit batch: still wins. An input longer than the cap can no longer be pooled in one pass — the accepted tradeoff, since a batch that OOMs the device processes nothing. Assisted-by: Claude:claude-opus-4-8 golangci-lint go-test Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(config): single-pass batch follows context on unknown VRAM The single-pass (embedding/score/rerank) batch cap must only shrink the batch when the per-device VRAM ceiling is KNOWN. On unknown VRAM (CPU-only or a GPU detection gap) SinglePassBatchForContext returned DefaultPhysicalBatch, which under-sized the batch below the context — over-trimming score/embed/rerank inputs (the modelTokenTrim middleware regression) with no OOM benefit on CPU where the compute buffer lives in system RAM. Return the full context instead, preserving the original single-pass behavior; the VRAM cap stays a downward safety that only engages when VRAM is known. Assisted-by: Claude:claude-opus-4-8 [go-test go-vet] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent ed3b59b commit 85f5267

4 files changed

Lines changed: 174 additions & 4 deletions

File tree

core/backend/options.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,24 @@ func EffectiveContextSize(c config.ModelConfig) int {
223223
return DefaultContextSize
224224
}
225225

226+
// localGPU resolves the device that will run the model, for single-pass batch
227+
// sizing. It is a package var so tests inject a deterministic device; production
228+
// reads config.LocalGPU, whose detection is sync.Once-cached in xsysinfo — so the
229+
// per-request call from the router's prompt trimmer (modelTokenTrim) stays cheap.
230+
var localGPU = config.LocalGPU
231+
226232
// EffectiveBatchSize is the single-decode batch the backend will run with.
227233
// Score, embedding and rerank all process the whole input in one pass: score
228234
// decodes prompt+candidate (asserts n_tokens <= n_batch), and embedding/rerank
229-
// pool over the full sequence in one physical batch (n_ubatch). So the batch
230-
// is sized to the context — anything that fits the context fits one pass,
235+
// pool over the full sequence in one physical batch (n_ubatch). Ideally the batch
236+
// covers the whole context so any input that fits the context fits one pass,
231237
// avoiding both the GGML_ASSERT crash and the "input is too large to process"
232-
// error. Explicit `batch:` always wins.
238+
// error — BUT a full ctx-sized n_ubatch makes the per-device CUDA compute buffer
239+
// multi-GiB (it scales ~ n_ubatch * n_ctx and can't be split across GPUs), so a
240+
// large-context embedding model aborts on load with free VRAM to spare (#10485).
241+
// So we cap the batch to the largest that fits the per-device VRAM headroom; an
242+
// input longer than that cap is the accepted tradeoff (it can't be pooled in one
243+
// pass, but the load no longer OOMs). Explicit `batch:` always wins.
233244
func EffectiveBatchSize(c config.ModelConfig) int {
234245
if c.Batch != 0 {
235246
return c.Batch
@@ -238,7 +249,7 @@ func EffectiveBatchSize(c config.ModelConfig) int {
238249
c.HasUsecases(config.FLAG_EMBEDDINGS) ||
239250
c.HasUsecases(config.FLAG_RERANK)
240251
if ctx := EffectiveContextSize(c); singlePass && ctx > DefaultBatchSize {
241-
return ctx
252+
return config.SinglePassBatchForContext(localGPU(), ctx)
242253
}
243254
return DefaultBatchSize
244255
}

core/backend/options_internal_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,19 @@ var _ = Describe("grpcModelOpts NBatch", func() {
103103
threads := 1
104104
ctx := 4096
105105

106+
// The single-pass batch is now VRAM-aware, so inject a deterministic GPU with
107+
// ample per-device VRAM: at these small contexts the compute buffer fits
108+
// easily, so EffectiveBatchSize returns the full context (the pre-#10485
109+
// behaviour these cases assert). Without injection the value would depend on
110+
// the CI host's real (often unknown) VRAM.
111+
const gib = uint64(1) << 30
112+
var origLocalGPU func() config.GPU
113+
BeforeEach(func() {
114+
origLocalGPU = localGPU
115+
localGPU = func() config.GPU { return config.GPU{VRAM: 119 * gib} }
116+
})
117+
AfterEach(func() { localGPU = origLocalGPU })
118+
106119
It("defaults to 512 for an ordinary model", func() {
107120
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
108121
opts := grpcModelOpts(cfg, "/tmp/models")
@@ -162,6 +175,61 @@ var _ = Describe("grpcModelOpts NBatch", func() {
162175
})
163176
})
164177

178+
// Guards the VRAM-aware cap on the single-pass (embedding/score/rerank) batch:
179+
// a large context must not turn n_ubatch into a multi-GiB compute buffer that
180+
// aborts the load on a device with free VRAM (issue #10485). The GPU is injected
181+
// via the localGPU package var so the cap is deterministic without a real device.
182+
var _ = Describe("EffectiveBatchSize VRAM cap", func() {
183+
const gib = uint64(1) << 30
184+
embeddings := config.FLAG_EMBEDDINGS
185+
threads := 1
186+
187+
var origLocalGPU func() config.GPU
188+
BeforeEach(func() { origLocalGPU = localGPU })
189+
AfterEach(func() { localGPU = origLocalGPU })
190+
191+
singlePassCfg := func(ctx int) config.ModelConfig {
192+
return config.ModelConfig{
193+
Threads: &threads,
194+
LLMConfig: config.LLMConfig{ContextSize: &ctx},
195+
KnownUsecases: &embeddings,
196+
}
197+
}
198+
199+
It("caps a large embedding context to a batch below the context but at least the default", func() {
200+
// Reproduces qwen3-embedding-4b: context 40960 on a modest 20 GiB card.
201+
// Full-context n_ubatch=40960 aborts; the cap must fit the VRAM headroom.
202+
localGPU = func() config.GPU { return config.GPU{VRAM: 20 * gib} }
203+
batch := EffectiveBatchSize(singlePassCfg(40960))
204+
Expect(batch).To(BeNumerically(">=", DefaultBatchSize))
205+
Expect(batch).To(BeNumerically("<", 40960))
206+
})
207+
208+
It("keeps an explicit batch even with a large context and small VRAM", func() {
209+
localGPU = func() config.GPU { return config.GPU{VRAM: 20 * gib} }
210+
cfg := singlePassCfg(40960)
211+
cfg.Batch = 512
212+
Expect(EffectiveBatchSize(cfg)).To(Equal(512))
213+
})
214+
215+
It("returns the full context when per-device VRAM is unknown", func() {
216+
// Unknown VRAM (CPU / detection gap) preserves the original single-pass
217+
// behavior: batch follows context. The VRAM cap is a downward safety that
218+
// only engages when the per-device ceiling is known — clamping here would
219+
// re-break single-pass pooling and over-trim inputs, with no OOM benefit on
220+
// CPU where the compute buffer lives in system RAM.
221+
localGPU = func() config.GPU { return config.GPU{VRAM: 0} }
222+
Expect(EffectiveBatchSize(singlePassCfg(40960))).To(Equal(40960))
223+
})
224+
225+
It("returns the default batch for a non-single-pass model regardless of VRAM", func() {
226+
localGPU = func() config.GPU { return config.GPU{VRAM: 20 * gib} }
227+
ctx := 40960
228+
cfg := config.ModelConfig{Threads: &threads, LLMConfig: config.LLMConfig{ContextSize: &ctx}}
229+
Expect(EffectiveBatchSize(cfg)).To(Equal(DefaultBatchSize))
230+
})
231+
})
232+
165233
// Guards the generic chat_template_kwargs forwarding: the model config map plus any
166234
// per-request metadata overrides are merged, coerced, and serialised into the
167235
// backend metadata blob that llama.cpp reads. Client metadata also overrides the

core/config/hardware_defaults.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,51 @@ func largeContextForDevice(g GPU, ctx int) bool {
149149
return extra > g.VRAM/blackwellBatchHeadroomDivisor
150150
}
151151

152+
// SinglePassBatchForContext caps the physical batch (n_batch / n_ubatch) for a
153+
// single-pass load — embedding, score and rerank all decode/pool the whole input
154+
// in ONE physical batch, so they want a batch >= the input length to avoid the
155+
// GGML_ASSERT(n_tokens <= n_batch) abort and the "input is too large to process"
156+
// error. The naive choice is batch == context, but n_ubatch == context turns the
157+
// per-device CUDA compute buffer (which scales ~ n_ubatch * n_ctx and is NOT
158+
// split across GPUs) into multi-GiB of scratch that must fit on a SINGLE card, so
159+
// a large-context embedding model aborts on load (exitCode=-1) even with plenty
160+
// of free VRAM — the same #10485 root cause the Blackwell batch boost guards
161+
// against, which the single-pass path previously bypassed entirely.
162+
//
163+
// So instead of the full context we return the LARGEST batch whose compute buffer
164+
// fits the per-device VRAM headroom (VRAM / blackwellBatchHeadroomDivisor),
165+
// clamped to [DefaultPhysicalBatch, ctx]. The tradeoff: an input longer than the
166+
// returned cap can no longer be pooled in a single pass — but a batch that OOMs
167+
// the device processes nothing at all.
168+
//
169+
// g.VRAM must be the PER-DEVICE ceiling (smallest device on a multi-GPU host).
170+
// VRAM 0 (unknown — CPU-only or a detection gap) returns the full context,
171+
// preserving the original single-pass behavior (batch follows context): the cap
172+
// is a DOWNWARD safety that only engages when the per-device ceiling is known.
173+
// Returning a smaller batch on unknown VRAM would re-break single-pass pooling
174+
// (n_tokens > n_batch) and over-trim score/embed/rerank inputs, with no OOM
175+
// benefit on CPU where the buffer lives in system RAM.
176+
func SinglePassBatchForContext(g GPU, ctx int) int {
177+
if ctx <= DefaultPhysicalBatch {
178+
return DefaultPhysicalBatch
179+
}
180+
if g.VRAM == 0 {
181+
return ctx
182+
}
183+
perBatchCell := uint64(ctx) * computeBufferBytesPerCell
184+
if perBatchCell == 0 {
185+
return DefaultPhysicalBatch
186+
}
187+
batchCap := int(g.VRAM / blackwellBatchHeadroomDivisor / perBatchCell)
188+
if batchCap < DefaultPhysicalBatch {
189+
return DefaultPhysicalBatch
190+
}
191+
if batchCap > ctx {
192+
return ctx
193+
}
194+
return batchCap
195+
}
196+
152197
// IsManagedPhysicalBatch reports whether n is a value PhysicalBatch assigns.
153198
// Callers that re-tune a value chosen by an upstream host (the distributed
154199
// router correcting the frontend's guess) use this to avoid clobbering an
@@ -254,6 +299,14 @@ var localGPU = func() GPU {
254299
}
255300
}
256301

302+
// LocalGPU exposes the locally-detected device descriptor to other packages
303+
// (e.g. core/backend's single-pass batch sizing) so they resolve the same
304+
// per-device VRAM this package's heuristics reason about. It goes through the
305+
// injectable localGPU var, so a config-package test seam also affects callers.
306+
func LocalGPU() GPU {
307+
return localGPU()
308+
}
309+
257310
// ApplyHardwareDefaults fills ModelConfig values that depend on the target GPU
258311
// and were left unset by the user. Currently: a larger physical batch on
259312
// Blackwell. Explicit config always wins (we only touch zero values).

core/config/hardware_defaults_internal_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,41 @@ var _ = Describe("SetDefaults hardware defaults (single-instance)", func() {
4646
Expect(cfg.Batch).To(Equal(1024))
4747
})
4848
})
49+
50+
// SinglePassBatchForContext is the VRAM-aware cap for the single-pass
51+
// (embedding/score/rerank) batch — the compute buffer scales ~ n_ubatch * n_ctx
52+
// and must fit a single device, so a large context can't take the full context
53+
// as its batch (issue #10485).
54+
var _ = Describe("SinglePassBatchForContext", func() {
55+
const gib = uint64(1) << 30
56+
57+
It("returns the default when the context is at or below the default batch", func() {
58+
Expect(SinglePassBatchForContext(GPU{VRAM: 119 * gib}, DefaultPhysicalBatch)).To(Equal(DefaultPhysicalBatch))
59+
Expect(SinglePassBatchForContext(GPU{VRAM: 119 * gib}, 256)).To(Equal(DefaultPhysicalBatch))
60+
})
61+
62+
It("returns the full context when the compute buffer fits ample VRAM", func() {
63+
// 4096 ctx on 119 GiB: the compute buffer is tiny, so the batch covers
64+
// the whole context (single-pass pooling in one physical batch).
65+
Expect(SinglePassBatchForContext(GPU{VRAM: 119 * gib}, 4096)).To(Equal(4096))
66+
})
67+
68+
It("caps below the context when a large context would overflow the VRAM headroom", func() {
69+
batch := SinglePassBatchForContext(GPU{VRAM: 20 * gib}, 40960)
70+
Expect(batch).To(BeNumerically(">=", DefaultPhysicalBatch))
71+
Expect(batch).To(BeNumerically("<", 40960))
72+
// The compute buffer for the capped batch must fit VRAM/headroom.
73+
Expect(uint64(batch) * 40960 * computeBufferBytesPerCell).To(BeNumerically("<=", (20*gib)/blackwellBatchHeadroomDivisor))
74+
})
75+
76+
It("never caps below the default batch even when VRAM is very tight", func() {
77+
Expect(SinglePassBatchForContext(GPU{VRAM: 1 * gib}, 40960)).To(Equal(DefaultPhysicalBatch))
78+
})
79+
80+
It("returns the full context (unclamped) when per-device VRAM is unknown", func() {
81+
// Unknown VRAM (CPU / detection gap) preserves the original single-pass
82+
// behavior — the cap is a downward safety that only engages when VRAM is
83+
// known. Clamping here would over-trim score/embed/rerank inputs.
84+
Expect(SinglePassBatchForContext(GPU{VRAM: 0}, 40960)).To(Equal(40960))
85+
})
86+
})

0 commit comments

Comments
 (0)