Skip to content

Commit 5945b0e

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
feat(vllm-cpp): wire the full engine config surface through engine_args
The vllm-cpp backend could configure four of the engine's knobs - block size, KV block count, max sequence length and max concurrent sequences - out of a config surface that is considerably larger. Speculative decoding, prefix caching, the chunked-prefill token budget, the scheduling policy and the external KV connector were all reachable from vllm.cpp's own HTTP server and from nothing LocalAI could write in a model config. Part of that gap was the C ABI itself, which carried strictly less than EngineParams does; that is fixed upstream in vllm.cpp ABI v9 (this bumps the pin to it). The rest was here: the backend parsed a flat `options:` list with five recognised keys and had no way to express a nested JSON document at all. Configuration now goes through `engine_args:`, the same map the vLLM and SGLang backends already take, with keys spelled as vLLM's own CLI flags - so a `speculative_config` or `kv_transfer_config` block written for vLLM works verbatim: engine_args: max_num_batched_tokens: 8192 enable_prefix_caching: true scheduling_policy: lpm speculative_config: method: dflash model: z-lab/Qwen3.6-27B-DFlash num_speculative_tokens: 4 kv_transfer_config: kv_connector: LMCacheConnector kv_role: kv_both kv_connector_extra_config: {host: 127.0.0.1, port: 65432} The `options:` list keeps working, and now reads every key too, so no existing config breaks; engine_args wins where both set the same key. Two details worth calling out. `enable_prefix_caching: false` maps to the ABI's force-OFF state (2), not the 0 that means "let the model capability decide" - collapsing them would silently turn the cache ON for the dense architectures that default it on. And cSamplingParams grows the ABI v8 logits-processor tail: LocalAI installs no processor, but the C side reads those fields off the pointer we hand it, so a Go struct that stopped short would have had the engine read 16 bytes past our allocation and call whatever sat there. The importer gets the safetensors counterpart of the llama-cpp MTP hook: a `vllm-cpp` import of a HuggingFace repo probes config.json and, on a checkpoint that declares an MTP head, writes speculative_config {method: mtp} into the generated engine_args. DFlash draft repositories are detected and refused with a warning rather than configured as standalone models, since a drafter cannot serve alone. The llama-cpp importer stops applying its own `spec_type:draft-mtp` options when the chosen backend is vllm-cpp: those are llama.cpp option keys vllm-cpp does not read, and vllm.cpp rejects MTP over a GGUF source anyway because the `mtp.*` draft tensors only exist in the safetensors checkpoint. docs/content/features/text-generation.md gains a vllm.cpp section - the backend had no documentation page at all - covering the engine_args table, all three speculative methods, LMCache, and the legacy options list. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [ClaudeCode]
1 parent bc21f83 commit 5945b0e

11 files changed

Lines changed: 983 additions & 28 deletions

File tree

backend/go/vllm-cpp/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
1111

1212
# vllm.cpp version
1313
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
14-
VLLM_CPP_VERSION?=9e1c9025ae61167a3335454d7cc0de6093c21845
14+
VLLM_CPP_VERSION?=974d9d72c2365d20dc76dbce4a98fddac32525c0
1515

1616
# The backend consumes only the stable C ABI (libvllm + include/vllm.h), so the
1717
# server, examples and tests of the engine are never built here.

backend/go/vllm-cpp/backend.go

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -116,34 +116,60 @@ func (v *VllmCpp) Load(opts *pb.ModelOptions) error {
116116
if v.opts.numBlocks > 0 {
117117
mp.NumBlocks = v.opts.numBlocks
118118
}
119+
// Sequence-length precedence, narrowest source last: context_size is the
120+
// generic LocalAI knob every backend honours, max_model_len is the
121+
// vLLM-specific one, and engine_args.max_model_len is the explicit
122+
// vllm-cpp override.
119123
if opts.ContextSize > 0 {
120124
mp.MaxModelLen = opts.ContextSize
121125
}
126+
if opts.MaxModelLen > 0 {
127+
mp.MaxModelLen = opts.MaxModelLen
128+
}
129+
if v.opts.maxModelLen > 0 {
130+
mp.MaxModelLen = v.opts.maxModelLen
131+
}
122132
if v.opts.maxNumSeqs > 0 {
123133
mp.MaxNumSeqs = v.opts.maxNumSeqs
124134
}
135+
if v.opts.maxNumBatchedTokens > 0 {
136+
mp.MaxNumBatchedTokens = v.opts.maxNumBatchedTokens
137+
}
138+
mp.EnablePrefixCaching = v.opts.enablePrefixCaching
125139

140+
// Every string below is borrowed by C for the duration of the load call
141+
// only (the library copies what it keeps), so the backing slices just have
142+
// to outlive vllmEngineLoad - hence the single KeepAlive after it.
126143
modelC := cString(model)
127144
mp.ModelPath = uintptr(unsafe.Pointer(&modelC[0])) // #nosec G103 -- borrowed by C for the load call only
128-
var toolParserC, reasoningParserC []byte
129-
if v.opts.toolParser != "" {
130-
toolParserC = cString(v.opts.toolParser)
131-
mp.ToolParser = uintptr(unsafe.Pointer(&toolParserC[0])) // #nosec G103 -- borrowed by C for the load call only
132-
}
133-
if v.opts.reasoningParser != "" {
134-
reasoningParserC = cString(v.opts.reasoningParser)
135-
mp.ReasoningParser = uintptr(unsafe.Pointer(&reasoningParserC[0])) // #nosec G103 -- borrowed by C for the load call only
145+
keep := [][]byte{modelC}
146+
setStr := func(dst *uintptr, s string) {
147+
if s == "" {
148+
return
149+
}
150+
b := cString(s)
151+
keep = append(keep, b)
152+
*dst = uintptr(unsafe.Pointer(&b[0])) // #nosec G103 -- borrowed by C for the load call only
136153
}
154+
setStr(&mp.ToolParser, v.opts.toolParser)
155+
setStr(&mp.ReasoningParser, v.opts.reasoningParser)
156+
setStr(&mp.SpeculativeConfig, v.opts.speculativeConfig)
157+
setStr(&mp.KVTransferConfig, v.opts.kvTransferConfig)
158+
setStr(&mp.SchedulingPolicy, v.opts.schedulingPolicy)
159+
setStr(&mp.TokenizerConfigPath, v.opts.tokenizerConfigPath)
137160

138161
xlog.Info("[vllm-cpp] Load", "model", model, "engine", vllmVersion(),
139162
"blockSize", mp.BlockSize, "numBlocks", mp.NumBlocks,
140-
"maxModelLen", mp.MaxModelLen, "maxNumSeqs", mp.MaxNumSeqs)
163+
"maxModelLen", mp.MaxModelLen, "maxNumSeqs", mp.MaxNumSeqs,
164+
"maxNumBatchedTokens", mp.MaxNumBatchedTokens,
165+
"prefixCaching", prefixCachingName(mp.EnablePrefixCaching),
166+
"schedulingPolicy", v.opts.schedulingPolicy,
167+
"speculativeConfig", v.opts.speculativeConfig,
168+
"kvTransferConfig", v.opts.kvTransferConfig)
141169

142170
var engine uintptr
143171
rc := vllmEngineLoad(unsafe.Pointer(&mp), unsafe.Pointer(&engine)) // #nosec G103 -- POD out-params
144-
runtime.KeepAlive(modelC)
145-
runtime.KeepAlive(toolParserC)
146-
runtime.KeepAlive(reasoningParserC)
172+
runtime.KeepAlive(keep)
147173
if rc != vllmOK {
148174
return fmt.Errorf("vllm-cpp: engine load failed: %s", vllmLastError())
149175
}

backend/go/vllm-cpp/govllmcpp.go

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

3-
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v2).
3+
// purego bindings for the vllm.cpp stable C ABI (include/vllm.h, ABI v9).
44
//
55
// The structs below are hand-mirrored PODs of the C declarations, with
66
// explicit padding so the Go layout matches the C layout on linux/darwin
@@ -18,23 +18,52 @@ import (
1818
)
1919

2020
// abiVersion is the VLLM_ABI_VERSION this file mirrors (vllm.h).
21-
const abiVersion = 5
21+
const abiVersion = 9
22+
23+
// Prefix-caching tri-state (vllm_model_params.enable_prefix_caching, ABI v7).
24+
// 0 is the model-capability default, which is what a config that says nothing
25+
// about prefix caching must produce.
26+
const (
27+
prefixCachingModelDefault int32 = 0
28+
prefixCachingOn int32 = 1
29+
prefixCachingOff int32 = 2
30+
)
31+
32+
// prefixCachingName renders the tri-state for the load log line, where "0"
33+
// would otherwise read as "off" rather than "whatever the model defaults to".
34+
func prefixCachingName(state int32) string {
35+
switch state {
36+
case prefixCachingOn:
37+
return "on"
38+
case prefixCachingOff:
39+
return "off"
40+
default:
41+
return "model-default"
42+
}
43+
}
2244

2345
// vllm_status (vllm.h).
2446
const (
2547
vllmOK = 0
2648
)
2749

28-
// cModelParams mirrors vllm_model_params.
50+
// cModelParams mirrors vllm_model_params. Every field is naturally aligned on
51+
// LP64 (the int32 pairs sit together), so the Go layout needs no explicit
52+
// padding; the offsets are asserted in vllmcpp_test.go.
2953
type cModelParams struct {
3054
ModelPath uintptr // const char*
31-
TokenizerConfigPath uintptr // const char*
55+
TokenizerConfigPath uintptr // const char*; NULL = <model_dir>/... (ABI v9)
3256
BlockSize int32
3357
NumBlocks int32
3458
MaxModelLen int32
3559
MaxNumSeqs int32
3660
ToolParser uintptr // const char*; NULL = auto-detect (ABI v4)
3761
ReasoningParser uintptr // const char*; NULL = auto-detect (ABI v5)
62+
SpeculativeConfig uintptr // const char* JSON; NULL = no speculation (ABI v6)
63+
EnablePrefixCaching int32 // tri-state 0/1/2 (ABI v7)
64+
MaxNumBatchedTokens int32 // <= 0 = per-arch default (ABI v9)
65+
SchedulingPolicy uintptr // const char*; NULL = "fcfs" (ABI v9)
66+
KVTransferConfig uintptr // const char* JSON; NULL = no connector (ABI v9)
3867
}
3968

4069
// cSamplingParams mirrors vllm_sampling_params (ABI v2, structured fields
@@ -65,6 +94,12 @@ type cSamplingParams struct {
6594
StructuredGrammar uintptr // const char*
6695
StructuredJSONObject int32
6796
_ [4]byte
97+
// ABI v8 tail. LocalAI installs no custom logits processor, but the fields
98+
// MUST be mirrored: the C side reads them off the pointer we hand it, so a
99+
// Go struct that stopped at StructuredJSONObject would have the engine read
100+
// 16 bytes past our allocation and call whatever garbage sat there.
101+
LogitsProcessor uintptr // vllm_logits_processor; NULL = none
102+
LogitsProcessorUserData uintptr // void*
68103
}
69104

70105
// cCompletion mirrors vllm_completion.

backend/go/vllm-cpp/options.go

Lines changed: 167 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,72 @@
11
package main
22

3-
// Engine-sizing knobs carried through the model config's free-form
4-
// `options:` list ("key:value" entries), mirroring how the other in-house
5-
// backends pass engine-specific settings that have no proto field.
3+
// Load-time engine configuration, from two config surfaces:
4+
//
5+
// - `engine_args:` (ModelOptions.EngineArgs, a JSON object) is the canonical
6+
// one. Keys are spelled exactly as vLLM's own CLI flags, so a config written
7+
// against vLLM works verbatim here - `speculative_config` and
8+
// `kv_transfer_config` in particular take the same JSON documents vLLM's
9+
// --speculative-config / --kv-transfer-config accept, and are handed to the
10+
// engine unparsed.
11+
// - `options:` (the free-form "key:value" list) is the older surface this
12+
// backend shipped with. It is still honoured so existing configs keep
13+
// working; engine_args wins on any key set in both.
14+
//
15+
// Anything unrecognised is ignored rather than fatal: the engine validates the
16+
// documents it is given and reports a precise error at load, and a config that
17+
// also carries knobs for a different backend must not fail the load here.
618

719
import (
20+
"encoding/json"
821
"strconv"
922
"strings"
1023

1124
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
25+
"github.com/mudler/xlog"
1226
)
1327

1428
type loadOptions struct {
1529
blockSize int32 // KV block size (tokens/block); engine default 32.
1630
numBlocks int32 // KV blocks to allocate; engine default 256.
1731
maxNumSeqs int32 // max concurrent sequences; engine default 8.
32+
// Max sequence length. Also settable through the model config's
33+
// context_size / max_model_len; see Load for the precedence.
34+
maxModelLen int32
35+
// Per-step chunked-prefill token budget (ABI v9). 0 = the engine's
36+
// bounded per-arch default.
37+
maxNumBatchedTokens int32
38+
// Automatic prefix caching tri-state (ABI v7): 0 = the model-capability
39+
// default, 1 = force on, 2 = force off.
40+
enablePrefixCaching int32
41+
// Scheduler admission policy (ABI v9): "" = fcfs, else fcfs|priority|lpm.
42+
schedulingPolicy string
1843
// Engine-side parser selection (ABI v4/v5). Empty = the engine
1944
// auto-detects from the chat template; "none" disables the reasoning
2045
// split; unknown names fail the first chat call.
2146
toolParser string
2247
reasoningParser string
48+
// Speculative decoding (ABI v6), as vLLM's --speculative-config JSON:
49+
// {"method":"mtp"|"dflash"|"ngram", ...}. Empty = no speculation.
50+
speculativeConfig string
51+
// External KV connector / LMCache (ABI v9), as vLLM's --kv-transfer-config
52+
// JSON. Empty = no connector.
53+
kvTransferConfig string
54+
// Override for the tokenizer_config.json the chat template is read from
55+
// (ABI v9). Empty = <model_dir>/tokenizer_config.json.
56+
tokenizerConfigPath string
2357
}
2458

2559
func parseOptions(opts *pb.ModelOptions) loadOptions {
2660
lo := loadOptions{}
27-
for _, o := range opts.GetOptions() {
61+
applyOptionsList(&lo, opts.GetOptions())
62+
applyEngineArgs(&lo, opts.GetEngineArgs())
63+
return lo
64+
}
65+
66+
// applyOptionsList reads the legacy free-form "key:value" list. strings.Cut
67+
// splits on the FIRST colon only, so a JSON object value survives intact.
68+
func applyOptionsList(lo *loadOptions, options []string) {
69+
for _, o := range options {
2870
k, v, found := strings.Cut(o, ":")
2971
if !found {
3072
continue
@@ -36,13 +78,132 @@ func parseOptions(opts *pb.ModelOptions) loadOptions {
3678
lo.numBlocks = parseInt32(v, lo.numBlocks)
3779
case "max_num_seqs":
3880
lo.maxNumSeqs = parseInt32(v, lo.maxNumSeqs)
39-
case "tool_parser":
81+
case "max_num_batched_tokens":
82+
lo.maxNumBatchedTokens = parseInt32(v, lo.maxNumBatchedTokens)
83+
case "max_model_len":
84+
lo.maxModelLen = parseInt32(v, lo.maxModelLen)
85+
case "scheduling_policy", "schedule_policy":
86+
lo.schedulingPolicy = strings.TrimSpace(v)
87+
case "tool_parser", "tool_call_parser":
4088
lo.toolParser = strings.TrimSpace(v)
4189
case "reasoning_parser":
4290
lo.reasoningParser = strings.TrimSpace(v)
91+
case "speculative_config":
92+
lo.speculativeConfig = strings.TrimSpace(v)
93+
case "kv_transfer_config":
94+
lo.kvTransferConfig = strings.TrimSpace(v)
95+
case "tokenizer_config", "tokenizer_config_path":
96+
lo.tokenizerConfigPath = strings.TrimSpace(v)
97+
case "enable_prefix_caching", "enable_radix_attention":
98+
if b, err := strconv.ParseBool(strings.TrimSpace(v)); err == nil {
99+
lo.enablePrefixCaching = prefixCachingTriState(b)
100+
}
43101
}
44102
}
45-
return lo
103+
}
104+
105+
// applyEngineArgs overlays the `engine_args:` JSON object. A document that does
106+
// not parse is logged and skipped: engine_args is shared with the other engines
107+
// (the vLLM and SGLang backends read the same field), so a stray key must not
108+
// take the model down.
109+
func applyEngineArgs(lo *loadOptions, engineArgs string) {
110+
if strings.TrimSpace(engineArgs) == "" {
111+
return
112+
}
113+
var args map[string]any
114+
if err := json.Unmarshal([]byte(engineArgs), &args); err != nil {
115+
xlog.Warn("[vllm-cpp] ignoring unparseable engine_args", "error", err)
116+
return
117+
}
118+
for k, v := range args {
119+
switch k {
120+
case "block_size":
121+
lo.blockSize = jsonInt32(v, lo.blockSize)
122+
case "num_blocks":
123+
lo.numBlocks = jsonInt32(v, lo.numBlocks)
124+
case "max_num_seqs":
125+
lo.maxNumSeqs = jsonInt32(v, lo.maxNumSeqs)
126+
case "max_num_batched_tokens":
127+
lo.maxNumBatchedTokens = jsonInt32(v, lo.maxNumBatchedTokens)
128+
case "max_model_len":
129+
lo.maxModelLen = jsonInt32(v, lo.maxModelLen)
130+
case "scheduling_policy", "schedule_policy":
131+
lo.schedulingPolicy = jsonString(v, lo.schedulingPolicy)
132+
case "tool_parser", "tool_call_parser":
133+
lo.toolParser = jsonString(v, lo.toolParser)
134+
case "reasoning_parser":
135+
lo.reasoningParser = jsonString(v, lo.reasoningParser)
136+
case "tokenizer_config", "tokenizer_config_path":
137+
lo.tokenizerConfigPath = jsonString(v, lo.tokenizerConfigPath)
138+
case "speculative_config":
139+
lo.speculativeConfig = jsonDocument(v, lo.speculativeConfig, k)
140+
case "kv_transfer_config":
141+
lo.kvTransferConfig = jsonDocument(v, lo.kvTransferConfig, k)
142+
case "enable_prefix_caching", "enable_radix_attention":
143+
if b, ok := v.(bool); ok {
144+
lo.enablePrefixCaching = prefixCachingTriState(b)
145+
}
146+
default:
147+
xlog.Debug("[vllm-cpp] ignoring unknown engine_args key", "key", k)
148+
}
149+
}
150+
}
151+
152+
// prefixCachingTriState maps a YAML/JSON boolean onto the C ABI's tri-state. An
153+
// explicit `false` must reach the engine as force-OFF (2), NOT as the 0 that
154+
// means "let the model capability decide" - those differ for the hybrid archs,
155+
// which default the cache off, and for the dense ones, which default it on.
156+
func prefixCachingTriState(on bool) int32 {
157+
if on {
158+
return prefixCachingOn
159+
}
160+
return prefixCachingOff
161+
}
162+
163+
// jsonDocument normalises an object-valued engine_args entry to a JSON string
164+
// for the C ABI. YAML nesting arrives as a map (the natural spelling); a
165+
// pre-encoded JSON string is accepted too, since a config round-tripped through
166+
// a flat store may carry it that way.
167+
func jsonDocument(v any, fallback string, key string) string {
168+
switch t := v.(type) {
169+
case string:
170+
if strings.TrimSpace(t) == "" {
171+
return fallback
172+
}
173+
return t
174+
default:
175+
buf, err := json.Marshal(t)
176+
if err != nil {
177+
xlog.Warn("[vllm-cpp] ignoring unencodable engine_args value", "key", key, "error", err)
178+
return fallback
179+
}
180+
return string(buf)
181+
}
182+
}
183+
184+
func jsonString(v any, fallback string) string {
185+
s, ok := v.(string)
186+
if !ok {
187+
return fallback
188+
}
189+
return strings.TrimSpace(s)
190+
}
191+
192+
// jsonInt32 accepts the float64 a JSON number decodes to, plus the string
193+
// spelling a YAML config may produce. Non-positive values keep the fallback:
194+
// every knob this covers uses "<= 0 means the engine default".
195+
func jsonInt32(v any, fallback int32) int32 {
196+
switch t := v.(type) {
197+
case float64:
198+
if t <= 0 || t > 1<<31-1 {
199+
return fallback
200+
}
201+
return int32(t)
202+
case string:
203+
return parseInt32(t, fallback)
204+
default:
205+
return fallback
206+
}
46207
}
47208

48209
func parseInt32(s string, fallback int32) int32 {

0 commit comments

Comments
 (0)