Skip to content

Commit 7978312

Browse files
localai-botmudler
andauthored
fix(config): gate parallel-slot default on per-device VRAM too (#10485) (#10507)
The first #10485 fix (#10494) made the Blackwell physical-batch boost per-device/context-aware, which neutralized the big compute-buffer OOM, but the reporter's 2x16 GiB consumer Blackwell still OOM'd. Tracing the post-fix log: the model now loads its weights, builds the main context and warms up fine, and dies only on the *last* allocation — the MTP draft context's 800 MiB KV cache on the tighter device. #10411 changed only two defaults: the physical batch (now gated) and a VRAM-scaled parallel-slot count. The KV cache is unified (n_ctx_seq == full context proves slots share the budget, so parallel doesn't multiply KV), but n_seq_max=4 still adds per-slot compute-graph / context-checkpoint / output scratch. On a device packed ~99% by a 27B model spanning both cards, that overhead is the few-hundred-MiB straw — which is why reverting #10411 (and only #10411) restores a working load. Gate the parallel-slot default on the same per-device headroom predicate as the batch boost: when a large context already fills a single card (largeContextForDevice), keep n_parallel=1. A user running one big-context model that barely fits across two consumer GPUs is not serving four concurrent tenants. Small contexts and large unified-memory devices (GB10) keep full concurrency. Applied on both the single-host path and the distributed router. Also make the auto-tuning visible and reversible (the debugging here needed DEBUG logs and a git bisect): - Log the effective performance-relevant runtime options at INFO once per model load ("effective runtime tuning …": context, n_batch, n_gpu_layers, parallel, flash_attention, f16) so an admin can see what will run and pin or override any value in the model YAML. - LOCALAI_DISABLE_HARDWARE_DEFAULTS=true skips the hardware auto-tuning entirely (mirrors LOCALAI_DISABLE_GUESSING) for stock llama.cpp behavior. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 4ac67d2 commit 7978312

7 files changed

Lines changed: 205 additions & 25 deletions

File tree

core/config/hardware_defaults.go

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,27 @@ package config
22

33
import (
44
"fmt"
5+
"os"
56
"strconv"
67
"strings"
78

89
"github.com/mudler/LocalAI/pkg/xsysinfo"
910
"github.com/mudler/xlog"
1011
)
1112

13+
// HardwareDefaultsDisabled reports whether hardware auto-tuning is turned off via
14+
// LOCALAI_DISABLE_HARDWARE_DEFAULTS=true (mirrors LOCALAI_DISABLE_GUESSING). When
15+
// set, ApplyHardwareDefaults and the distributed router's node tuning are
16+
// skipped entirely, so the backend runs llama.cpp's stock batch/parallel
17+
// behavior — an escape hatch for users who want predictable, un-tuned defaults.
18+
func HardwareDefaultsDisabled() bool {
19+
// Read directly like the sibling LOCALAI_DISABLE_GUESSING toggle in
20+
// hooks_llamacpp.go: these config-layer heuristic switches run deep in the
21+
// defaults pipeline with no ApplicationConfig in scope to plumb through.
22+
//nolint:forbidigo // config-layer heuristic toggle, mirrors LOCALAI_DISABLE_GUESSING
23+
return os.Getenv("LOCALAI_DISABLE_HARDWARE_DEFAULTS") == "true"
24+
}
25+
1226
// Hardware-driven model-config defaults.
1327
//
1428
// This sits alongside the other config overriders (ApplyInferenceDefaults for
@@ -103,17 +117,36 @@ func PhysicalBatchForContext(g GPU, ctx int) int {
103117
if !g.IsNVIDIABlackwell() {
104118
return DefaultPhysicalBatch
105119
}
106-
if ctx <= 0 {
107-
ctx = DefaultContextSize
108-
}
109120
if g.VRAM == 0 {
110121
return DefaultPhysicalBatch
111122
}
112-
extra := uint64(ctx) * uint64(BlackwellPhysicalBatch-DefaultPhysicalBatch) * computeBufferBytesPerCell
113-
if extra <= g.VRAM/blackwellBatchHeadroomDivisor {
114-
return BlackwellPhysicalBatch
123+
if largeContextForDevice(g, ctx) {
124+
return DefaultPhysicalBatch
115125
}
116-
return DefaultPhysicalBatch
126+
return BlackwellPhysicalBatch
127+
}
128+
129+
// largeContextForDevice reports whether the given context is large relative to
130+
// the per-device VRAM ceiling — the shared "tight single-model fit" signal that
131+
// suppresses BOTH throughput-oriented defaults (the Blackwell batch boost and
132+
// the concurrency slot count). It sizes the extra compute-buffer scratch a
133+
// raised batch would need at this context (which grows ~n_ubatch * n_ctx and
134+
// is allocated per device) and asks whether it overflows a fraction of the
135+
// device VRAM; when it does, the device has no headroom to spend on throughput
136+
// and the conservative defaults must hold (issue #10485).
137+
//
138+
// g.VRAM must be the PER-DEVICE ceiling (the smallest device on a multi-GPU
139+
// host). VRAM 0 (unknown) is treated as not-large so detection gaps don't
140+
// silently disable the defaults.
141+
func largeContextForDevice(g GPU, ctx int) bool {
142+
if g.VRAM == 0 {
143+
return false
144+
}
145+
if ctx <= 0 {
146+
ctx = DefaultContextSize
147+
}
148+
extra := uint64(ctx) * uint64(BlackwellPhysicalBatch-DefaultPhysicalBatch) * computeBufferBytesPerCell
149+
return extra > g.VRAM/blackwellBatchHeadroomDivisor
117150
}
118151

119152
// IsManagedPhysicalBatch reports whether n is a value PhysicalBatch assigns.
@@ -152,17 +185,50 @@ func DefaultParallelSlots(g GPU) int {
152185
}
153186
}
154187

155-
// EnsureParallelOption appends a VRAM-scaled "parallel:N" backend option when the
156-
// model doesn't already set one (and the GPU warrants concurrency). Returns the
157-
// possibly-extended options. Shared by the single-host config path
158-
// (ApplyHardwareDefaults) and the distributed router (per selected node).
159-
func EnsureParallelOption(opts []string, gpu GPU) []string {
160-
if slots := DefaultParallelSlots(gpu); slots > 1 && !hasParallelOption(opts) {
188+
// ParallelSlotsForContext is DefaultParallelSlots gated on per-device VRAM
189+
// headroom for the given context. A large context already claims most of a
190+
// single device's VRAM (the KV cache plus the per-slot compute/checkpoint
191+
// scratch that scales with n_seq_max), so defaulting multiple slots there
192+
// pushes a tight single-model fit into per-device CUDA OOM (issue #10485): the
193+
// model loads but the final allocation (e.g. an MTP draft context's KV cache)
194+
// overflows the tighter card by a few hundred MiB. Returns 1 (no concurrency)
195+
// in that tight regime, otherwise the VRAM-scaled DefaultParallelSlots.
196+
//
197+
// g.VRAM must be the PER-DEVICE ceiling (smallest device on a multi-GPU host).
198+
// It shares largeContextForDevice with the batch boost so both throughput
199+
// defaults are suppressed together; the GB10 / unified-memory path reports
200+
// system RAM and so keeps full concurrency even at large contexts.
201+
func ParallelSlotsForContext(g GPU, ctx int) int {
202+
slots := DefaultParallelSlots(g)
203+
if slots <= 1 || g.VRAM == 0 {
204+
return slots
205+
}
206+
if largeContextForDevice(g, ctx) {
207+
return 1
208+
}
209+
return slots
210+
}
211+
212+
// EnsureParallelOptionForContext appends a VRAM-scaled "parallel:N" backend
213+
// option when the model doesn't already set one and the GPU warrants (and has
214+
// headroom for) concurrency at this context. Returns the possibly-extended
215+
// options. Shared by the single-host config path (ApplyHardwareDefaults) and
216+
// the distributed router (per selected node).
217+
func EnsureParallelOptionForContext(opts []string, gpu GPU, ctx int) []string {
218+
if slots := ParallelSlotsForContext(gpu, ctx); slots > 1 && !hasParallelOption(opts) {
161219
return append(opts, fmt.Sprintf("parallel:%d", slots))
162220
}
163221
return opts
164222
}
165223

224+
// EnsureParallelOption is EnsureParallelOptionForContext with no known context
225+
// (defaults to DefaultContextSize, which clears the headroom gate on any device
226+
// large enough to warrant concurrency). Kept for callers without a model
227+
// context.
228+
func EnsureParallelOption(opts []string, gpu GPU) []string {
229+
return EnsureParallelOptionForContext(opts, gpu, 0)
230+
}
231+
166232
// hasParallelOption reports whether the model already sets parallel/n_parallel
167233
// so we never override an explicit value (helper shared with serving_defaults.go).
168234
func hasParallelOption(opts []string) bool {
@@ -192,18 +258,18 @@ var localGPU = func() GPU {
192258
// and were left unset by the user. Currently: a larger physical batch on
193259
// Blackwell. Explicit config always wins (we only touch zero values).
194260
func ApplyHardwareDefaults(cfg *ModelConfig, gpu GPU) {
195-
if cfg == nil {
261+
if cfg == nil || HardwareDefaultsDisabled() {
196262
return
197263
}
198264
// Raise the physical batch on Blackwell only when the resulting compute
199265
// buffer fits the per-device VRAM at THIS model's context. Leaving Batch at 0
200266
// (rather than writing the default 512) preserves the downstream single-pass
201267
// sizing in core/backend.EffectiveBatchSize for embedding/score/rerank.
268+
ctx := DefaultContextSize
269+
if cfg.ContextSize != nil {
270+
ctx = *cfg.ContextSize
271+
}
202272
if cfg.Batch == 0 {
203-
ctx := DefaultContextSize
204-
if cfg.ContextSize != nil {
205-
ctx = *cfg.ContextSize
206-
}
207273
if PhysicalBatchForContext(gpu, ctx) == BlackwellPhysicalBatch {
208274
cfg.Batch = BlackwellPhysicalBatch
209275
xlog.Debug("[hardware_defaults] Blackwell GPU: defaulting physical batch",
@@ -214,13 +280,14 @@ func ApplyHardwareDefaults(cfg *ModelConfig, gpu GPU) {
214280
// Enable concurrent serving by default on a capable GPU: without this the
215281
// llama.cpp backend runs n_parallel=1 and serializes multi-user requests
216282
// (continuous batching stays off). Unified KV means the slots share the
217-
// context budget, so this is concurrency without extra KV memory. Explicit
218-
// parallel/n_parallel in the model options always wins.
283+
// context budget, but a context large enough to fill a single device leaves
284+
// no room for the per-slot scratch, so the slot count is gated on per-device
285+
// headroom too (issue #10485). Explicit parallel/n_parallel always wins.
219286
if before := len(cfg.Options); true {
220-
cfg.Options = EnsureParallelOption(cfg.Options, gpu)
287+
cfg.Options = EnsureParallelOptionForContext(cfg.Options, gpu, ctx)
221288
if len(cfg.Options) > before {
222289
xlog.Debug("[hardware_defaults] defaulting parallel slots for concurrent serving",
223-
"option", cfg.Options[len(cfg.Options)-1], "vram_gib", gpu.VRAM>>30)
290+
"option", cfg.Options[len(cfg.Options)-1], "context", ctx, "vram_gib", gpu.VRAM>>30)
224291
}
225292
}
226293
}

core/config/hardware_defaults_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ var _ = Describe("Hardware-driven config defaults", func() {
9090
It("no-ops on nil", func() {
9191
Expect(func() { ApplyHardwareDefaults(nil, GPU{ComputeCapability: "12.1"}) }).ToNot(Panic())
9292
})
93+
94+
It("applies nothing when hardware defaults are disabled via env", func() {
95+
GinkgoT().Setenv("LOCALAI_DISABLE_HARDWARE_DEFAULTS", "true")
96+
Expect(HardwareDefaultsDisabled()).To(BeTrue())
97+
cfg := &ModelConfig{}
98+
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "12.1", VRAM: 119 * gib})
99+
Expect(cfg.Batch).To(Equal(0))
100+
Expect(cfg.Options).To(BeEmpty())
101+
})
93102
})
94103

95104
DescribeTable("DefaultParallelSlots (by VRAM)",
@@ -105,12 +114,46 @@ var _ = Describe("Hardware-driven config defaults", func() {
105114
Entry("unknown 0", uint64(0), 1),
106115
)
107116

117+
Describe("ParallelSlotsForContext (per-device VRAM headroom)", func() {
118+
It("keeps the VRAM-scaled slot count when the context fits the device", func() {
119+
// 16 GiB card, small context: plenty of room for concurrency.
120+
Expect(ParallelSlotsForContext(GPU{VRAM: 16 * gib}, 8192)).To(Equal(4))
121+
})
122+
It("drops to a single slot when a large context already fills the device", func() {
123+
// Regression guard for issue #10485: 16 GiB consumer Blackwell, ~200k
124+
// context. Even with unified KV, the per-slot compute/checkpoint
125+
// scratch from 4 slots is the straw that overflows the tighter device.
126+
Expect(ParallelSlotsForContext(GPU{VRAM: 16 * gib}, 204800)).To(Equal(1))
127+
})
128+
It("keeps concurrency on a large unified-memory device (GB10)", func() {
129+
// GB10 reports system RAM (~119 GiB): a 200k context leaves headroom.
130+
Expect(ParallelSlotsForContext(GPU{VRAM: 119 * gib}, 204800)).To(Equal(8))
131+
})
132+
It("keeps concurrency on a big datacenter card with a large context", func() {
133+
// 80 GiB A100: 200k context is a small fraction, concurrency stays.
134+
Expect(ParallelSlotsForContext(GPU{VRAM: 80 * gib}, 204800)).To(Equal(8))
135+
})
136+
It("stays a single slot on small/unknown VRAM regardless of context", func() {
137+
Expect(ParallelSlotsForContext(GPU{VRAM: 2 * gib}, 8192)).To(Equal(1))
138+
Expect(ParallelSlotsForContext(GPU{}, 8192)).To(Equal(1))
139+
})
140+
})
141+
108142
Describe("ApplyHardwareDefaults parallel slots", func() {
109143
It("adds a VRAM-scaled parallel option on a capable GPU", func() {
110144
cfg := &ModelConfig{}
111145
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "12.1", VRAM: 119 * gib})
112146
Expect(cfg.Options).To(ContainElement("parallel:8"))
113147
})
148+
It("adds no parallel option when a large context already fills one device", func() {
149+
// Regression guard for issue #10485: 16 GiB card + ~200k context. The
150+
// model barely fits; defaulting concurrency tips the tighter GPU into
151+
// CUDA OOM during the final (MTP draft) KV allocation.
152+
ctx := 204800
153+
cfg := &ModelConfig{LLMConfig: LLMConfig{ContextSize: &ctx}}
154+
ApplyHardwareDefaults(cfg, GPU{ComputeCapability: "12.0", VRAM: 16 * gib})
155+
Expect(cfg.Options).ToNot(ContainElement(ContainSubstring("parallel")))
156+
})
114157
It("scales the slot count down with VRAM", func() {
115158
cfg := &ModelConfig{}
116159
ApplyHardwareDefaults(cfg, GPU{VRAM: 24 * gib})

core/services/nodes/router.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ type scheduleLoadResult struct {
147147
// Only values the heuristics themselves manage are touched, so an explicit user
148148
// batch (e.g. 1024) is never overridden.
149149
func applyNodeHardwareDefaults(opts *pb.ModelOptions, node *BackendNode) {
150-
if opts == nil || node == nil {
150+
if opts == nil || node == nil || config.HardwareDefaultsDisabled() {
151151
return
152152
}
153153
gpu := config.GPU{
@@ -162,8 +162,11 @@ func applyNodeHardwareDefaults(opts *pb.ModelOptions, node *BackendNode) {
162162
opts.NBatch = int32(config.PhysicalBatchForContext(gpu, int(opts.ContextSize)))
163163
}
164164
// Default concurrent serving for the selected node (the frontend that built
165-
// the options may have no GPU). Only adds when no parallel option is set.
166-
opts.Options = config.EnsureParallelOption(opts.Options, gpu)
165+
// the options may have no GPU). Gated on the node's per-device VRAM at this
166+
// model's context, so a large context that already fills the device can't
167+
// tip it into OOM by adding slot scratch (issue #10485). Only adds when no
168+
// parallel option is set.
169+
opts.Options = config.EnsureParallelOptionForContext(opts.Options, gpu, int(opts.ContextSize))
167170
}
168171

169172
// scheduleAndLoad is the shared core for loading a model on a new node.

core/services/nodes/router_hardware_internal_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ var _ = Describe("applyNodeHardwareDefaults", func() {
4141
Expect(opts.Options).To(ContainElement("parallel:8"))
4242
})
4343

44+
It("adds no parallel option when a large context already fills the node device", func() {
45+
// Regression guard for issue #10485: a 16 GiB node with a ~200k context
46+
// is a tight single-model fit — the slot scratch would tip it into OOM.
47+
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch, ContextSize: 204800}
48+
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.0", TotalVRAM: 16 << 30})
49+
Expect(opts.Options).ToNot(ContainElement(ContainSubstring("parallel")))
50+
})
51+
4452
It("never overrides an explicit parallel option on the node path", func() {
4553
opts := &pb.ModelOptions{NBatch: config.DefaultPhysicalBatch, Options: []string{"parallel:2"}}
4654
applyNodeHardwareDefaults(opts, &BackendNode{GPUComputeCapability: "12.1", TotalVRAM: 119 << 30})

docs/content/features/text-generation.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,6 +537,16 @@ options:
537537

538538
**Note:** The `parallel` option can also be set via the `LLAMACPP_PARALLEL` environment variable, and `grpc_servers` can be set via the `LLAMACPP_GRPC_SERVERS` environment variable. Options specified in the YAML file take precedence over environment variables.
539539

540+
##### Hardware auto-tuning (and how to override it)
541+
542+
On a detected GPU, LocalAI fills a few performance-relevant defaults the model config leaves unset — a larger physical batch on NVIDIA Blackwell, and a VRAM-scaled `parallel` slot count for concurrent serving. Both are gated on **per-device** VRAM at the model's context: when a large context already fills a single card (e.g. a 27B model with a 200k context across 2×16 GiB), the batch boost and the extra parallel slots are suppressed so they can't tip the tighter GPU into CUDA out-of-memory.
543+
544+
Anything you set explicitly in the model YAML always wins, so to pin a value just set it (e.g. `batch: 512` or `options: ["parallel:1"]`). The effective values are logged at `INFO` when a model loads (`effective runtime tuning …`). To turn the hardware auto-tuning off entirely and run llama.cpp's stock behavior, set:
545+
546+
```
547+
LOCALAI_DISABLE_HARDWARE_DEFAULTS=true
548+
```
549+
540550
##### Server-side prompt cache (repeated system prompts)
541551

542552
Agents, coding assistants, and Anthropic/OpenAI-compatible CLIs typically resend the same large system prompt on every turn. The llama.cpp server can short-circuit prefill for the matching prefix by stashing idle slot KV states in host RAM and reloading them on a hit. Three settings interact:

pkg/model/initializers.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,41 @@ func (ml *ModelLoader) grpcModel(backend string, o *Options) func(string, string
169169
}
170170
}
171171

172+
// parallelSlotsFromOptions returns the effective n_parallel from the backend
173+
// option strings ("parallel:N" / "n_parallel:N"), or "1" when unset — the
174+
// llama.cpp default. Used only for the effective-tuning load log.
175+
func parallelSlotsFromOptions(opts []string) string {
176+
for _, o := range opts {
177+
k, v, ok := strings.Cut(o, ":")
178+
if ok && (k == "parallel" || k == "n_parallel") {
179+
return strings.TrimSpace(v)
180+
}
181+
}
182+
return "1"
183+
}
184+
172185
func (ml *ModelLoader) backendLoader(opts ...Option) (client grpc.Backend, err error) {
173186
o := NewOptions(opts...)
174187

175188
xlog.Info("BackendLoader starting", "modelID", o.modelID, "backend", o.backendString, "model", o.model)
176189

190+
// Surface the effective performance-relevant runtime options at load (some of
191+
// these are auto-tuned for the detected hardware). Logged once per load so an
192+
// admin can see what will actually run and pin or override any value in the
193+
// model YAML — or set LOCALAI_DISABLE_HARDWARE_DEFAULTS=true to turn the
194+
// hardware auto-tuning off entirely. Gated on an LLM-ish load (context set) so
195+
// TTS/audio/other backends stay quiet.
196+
if opt := o.gRPCOptions; opt != nil && opt.ContextSize > 0 {
197+
xlog.Info("effective runtime tuning (override in the model YAML; LOCALAI_DISABLE_HARDWARE_DEFAULTS=true disables hardware auto-tuning)",
198+
"modelID", o.modelID,
199+
"context", opt.ContextSize,
200+
"n_batch", opt.NBatch,
201+
"n_gpu_layers", opt.NGPULayers,
202+
"parallel", parallelSlotsFromOptions(opt.Options),
203+
"flash_attention", opt.FlashAttention,
204+
"f16", opt.F16Memory)
205+
}
206+
177207
backend := strings.ToLower(o.backendString)
178208
if realBackend, exists := Aliases[backend]; exists {
179209
typeAlias, exists := TypeAlias[backend]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package model
2+
3+
import (
4+
. "github.com/onsi/ginkgo/v2"
5+
. "github.com/onsi/gomega"
6+
)
7+
8+
var _ = Describe("parallelSlotsFromOptions", func() {
9+
It("reads the parallel slot count from the backend options", func() {
10+
Expect(parallelSlotsFromOptions([]string{"use_jinja:true", "parallel:4"})).To(Equal("4"))
11+
})
12+
It("accepts the n_parallel alias", func() {
13+
Expect(parallelSlotsFromOptions([]string{"n_parallel:8"})).To(Equal("8"))
14+
})
15+
It("defaults to a single slot when unset", func() {
16+
Expect(parallelSlotsFromOptions([]string{"use_jinja:true"})).To(Equal("1"))
17+
Expect(parallelSlotsFromOptions(nil)).To(Equal("1"))
18+
})
19+
})

0 commit comments

Comments
 (0)