Skip to content

Commit 2c804be

Browse files
authored
fix(config): skip vocab arrays and mmap GGUF headers to speed up startup (#10213)
When the models directory holds many GGUF files, startup parsed every model's full GGUF — including the tokenizer vocab arrays (tokenizer.ggml.tokens/scores/merges, often >100k entries) — once per model while guessing defaults. On slow storage (e.g. a models directory on a Docker volume) those hundreds of thousands of tiny reads dominate boot time before the HTTP server comes up. The default-guessing path and the VRAM metadata reader only consume scalar metadata and array lengths, never the array contents. Parse with SkipLargeMetadata (seek past large arrays) and UseMMap (fault in a few header pages instead of issuing per-element read() syscalls). For a 256k-token vocab this cuts the parse from ~524k read() syscalls to 8. The mapping is released when ParseGGUFFile returns. Fixes #9790 Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Adira Denis Muhando <dennisadira@gmail.com>
1 parent 6070402 commit 2c804be

3 files changed

Lines changed: 135 additions & 2 deletions

File tree

core/config/hooks_llamacpp.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,21 @@ func llamaCppDefaults(cfg *ModelConfig, modelPath string) {
3939
}
4040
}()
4141

42-
f, err := gguf.ParseGGUFFile(guessPath)
42+
// Startup parses every model's GGUF header to guess defaults. We only need
43+
// scalar metadata (architecture, head/ff counts, chat_template, token IDs,
44+
// MTP head) plus array *lengths* — never the array *contents*. Two options
45+
// keep this cheap, which matters when many models live on slow storage such
46+
// as a Docker volume (see https://github.com/mudler/LocalAI/issues/9790):
47+
//
48+
// - SkipLargeMetadata: seek past large array-valued metadata (the tokenizer
49+
// vocab: tokenizer.ggml.tokens/scores/merges, often >100k entries) instead
50+
// of reading and allocating every element. Lengths stay populated.
51+
// - UseMMap: read the header via a memory map so faulting in a few pages
52+
// replaces hundreds of thousands of tiny read() syscalls (measured ~524k
53+
// -> 8 for a 256k-token vocab), the dominant cost on slow filesystems.
54+
//
55+
// The mapping is released when ParseGGUFFile returns.
56+
f, err := gguf.ParseGGUFFile(guessPath, gguf.UseMMap(), gguf.SkipLargeMetadata())
4357
if err == nil {
4458
guessGGUFFromFile(cfg, f, 0)
4559
}

core/config/hooks_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,76 @@
11
package config_test
22

33
import (
4+
"bytes"
5+
"encoding/binary"
6+
"os"
7+
"path/filepath"
8+
49
. "github.com/mudler/LocalAI/core/config"
510
"github.com/mudler/LocalAI/core/schema"
611

12+
gguf "github.com/gpustack/gguf-parser-go"
713
. "github.com/onsi/ginkgo/v2"
814
. "github.com/onsi/gomega"
915
)
1016

17+
// GGUF metadata value type tags (see github.com/gpustack/gguf-parser-go).
18+
const (
19+
ggufTypeUint32 uint32 = 4
20+
ggufTypeString uint32 = 8
21+
ggufTypeArray uint32 = 9
22+
)
23+
24+
// writeTestGGUF emits a minimal but valid little-endian GGUF v3 header carrying
25+
// the scalar metadata the llama-cpp hook guesses from plus a large string vocab
26+
// array (tokenizer.ggml.tokens). The big array is exactly what SkipLargeMetadata
27+
// + UseMMap are expected to avoid reading element-by-element, so it must survive a
28+
// round-trip through the real hook without corrupting the guessed defaults.
29+
func writeTestGGUF(path, chatTemplate string, vocab int) error {
30+
wStr := func(b *bytes.Buffer, s string) {
31+
binary.Write(b, binary.LittleEndian, uint64(len(s)))
32+
b.WriteString(s)
33+
}
34+
kvStr := func(b *bytes.Buffer, k, v string) {
35+
wStr(b, k)
36+
binary.Write(b, binary.LittleEndian, ggufTypeString)
37+
wStr(b, v)
38+
}
39+
kvU32 := func(b *bytes.Buffer, k string, v uint32) {
40+
wStr(b, k)
41+
binary.Write(b, binary.LittleEndian, ggufTypeUint32)
42+
binary.Write(b, binary.LittleEndian, v)
43+
}
44+
45+
var meta bytes.Buffer
46+
kvStr(&meta, "general.architecture", "llama")
47+
kvStr(&meta, "general.name", "ReproModel")
48+
kvU32(&meta, "llama.context_length", 4096)
49+
kvU32(&meta, "llama.attention.head_count", 32)
50+
kvU32(&meta, "llama.feed_forward_length", 11008)
51+
kvU32(&meta, "llama.block_count", 32)
52+
kvU32(&meta, "tokenizer.ggml.bos_token_id", 1)
53+
kvStr(&meta, "tokenizer.chat_template", chatTemplate)
54+
55+
// large array value — the one the optimization skips reading
56+
wStr(&meta, "tokenizer.ggml.tokens")
57+
binary.Write(&meta, binary.LittleEndian, ggufTypeArray)
58+
binary.Write(&meta, binary.LittleEndian, ggufTypeString)
59+
binary.Write(&meta, binary.LittleEndian, uint64(vocab))
60+
for i := 0; i < vocab; i++ {
61+
wStr(&meta, "token")
62+
}
63+
64+
var out bytes.Buffer
65+
binary.Write(&out, binary.LittleEndian, gguf.GGUFMagicGGUFLe)
66+
binary.Write(&out, binary.LittleEndian, uint32(3)) // version
67+
binary.Write(&out, binary.LittleEndian, uint64(0)) // tensor count
68+
binary.Write(&out, binary.LittleEndian, uint64(9)) // metadata kv count
69+
out.Write(meta.Bytes())
70+
71+
return os.WriteFile(path, out.Bytes(), 0o644)
72+
}
73+
1174
var _ = Describe("Backend hooks and parser defaults", func() {
1275
Context("MatchParserDefaults", func() {
1376
It("matches Qwen3 family", func() {
@@ -137,6 +200,58 @@ var _ = Describe("Backend hooks and parser defaults", func() {
137200
})
138201
})
139202

203+
Context("llamaCppDefaults GGUF guessing", func() {
204+
// Regression coverage for https://github.com/mudler/LocalAI/issues/9790:
205+
// the hook reads GGUF headers with SkipLargeMetadata + UseMMap to avoid
206+
// pulling the whole tokenizer vocab off (slow) disk on every startup. This
207+
// verifies that skipping the vocab array still yields the correct guessed
208+
// defaults from the remaining scalar metadata.
209+
const chatTemplate = "{{ bos_token }}{% for m in messages %}{{ m.content }}{% endfor %}"
210+
211+
It("guesses defaults from a GGUF whose large vocab is skipped", func() {
212+
dir := GinkgoT().TempDir()
213+
modelFile := "repro.gguf"
214+
Expect(writeTestGGUF(filepath.Join(dir, modelFile), chatTemplate, 50000)).To(Succeed())
215+
216+
// A pre-set context size short-circuits the GGUF run-estimate, which
217+
// needs full tensor info this header-only fixture deliberately omits;
218+
// the metadata-reading path the optimization touches is unaffected.
219+
ctxSize := 4096
220+
cfg := &ModelConfig{
221+
Backend: "llama-cpp",
222+
LLMConfig: LLMConfig{ContextSize: &ctxSize},
223+
PredictionOptions: schema.PredictionOptions{
224+
BasicModelRequest: schema.BasicModelRequest{Model: modelFile},
225+
},
226+
}
227+
cfg.SetDefaults(ModelPath(dir))
228+
229+
// chat_template is a scalar string, not part of the skipped array,
230+
// so it must be captured verbatim.
231+
Expect(cfg.GetModelTemplate()).To(Equal(chatTemplate))
232+
// scalar-derived defaults are still applied
233+
Expect(cfg.ContextSize).NotTo(BeNil())
234+
Expect(cfg.NGPULayers).NotTo(BeNil())
235+
Expect(cfg.TemplateConfig.UseTokenizerTemplate).To(BeTrue())
236+
Expect(cfg.KnownUsecaseStrings).To(ContainElement("FLAG_CHAT"))
237+
})
238+
239+
It("falls back to the default context size when the GGUF is unreadable", func() {
240+
dir := GinkgoT().TempDir()
241+
Expect(os.WriteFile(filepath.Join(dir, "bad.gguf"), []byte("not a gguf"), 0o644)).To(Succeed())
242+
243+
cfg := &ModelConfig{
244+
Backend: "llama-cpp",
245+
PredictionOptions: schema.PredictionOptions{
246+
BasicModelRequest: schema.BasicModelRequest{Model: "bad.gguf"},
247+
},
248+
}
249+
cfg.SetDefaults(ModelPath(dir))
250+
251+
Expect(cfg.ContextSize).NotTo(BeNil())
252+
})
253+
})
254+
140255
Context("PromptCacheAll default", func() {
141256
It("defaults to true when omitted from YAML", func() {
142257
cfg := &ModelConfig{}

pkg/vram/gguf_reader.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ func (defaultGGUFReader) ReadMetadata(ctx context.Context, uri string) (*GGUFMet
1515
urlStr := u.ResolveURL()
1616

1717
if strings.HasPrefix(uri, downloader.LocalPrefix) {
18-
f, err := gguf.ParseGGUFFile(urlStr)
18+
// Only architecture scalars are read below, never the tokenizer vocab
19+
// arrays, so skip them and memory-map the header to avoid a syscall
20+
// storm on slow storage. Same rationale as the startup guessing path in
21+
// core/config/hooks_llamacpp.go (https://github.com/mudler/LocalAI/issues/9790).
22+
f, err := gguf.ParseGGUFFile(urlStr, gguf.UseMMap(), gguf.SkipLargeMetadata())
1923
if err != nil {
2024
return nil, err
2125
}

0 commit comments

Comments
 (0)