llm: size mmproj offload by projector memory (#16866)
* llm: size mmproj offload by projector memory Replace the blanket 10 GiB VRAM cutoff with a projector tensor-size estimate plus backend headroom, while preserving the existing CPU-only, partial text offload, shared-memory GPU, and startup OOM retry gates. This is a stopgap until fit accounts for mmproj memory directly. The same limited-vram path appears in the qwen3.5 vision hang report: the logs show --no-mmproj-offload on a 7.5 GiB RTX 5050 with about 6.4 GiB free while llama-server estimates the inline mmproj at about 962 MiB. Fixes #16496 Fixes #16570 * review comments
This commit is contained in:
@@ -160,6 +160,7 @@ type llamaServerLaunchConfig struct {
|
||||
modelPath string
|
||||
modelArch string
|
||||
projectors []string
|
||||
mmprojMemory uint64
|
||||
modelLayers uint64
|
||||
adapters []string
|
||||
opts api.Options
|
||||
@@ -602,7 +603,10 @@ func appendMainGPUArgs(params []string, opts api.Options) []string {
|
||||
return append(params, "--split-mode", "none", "--main-gpu", strconv.Itoa(*opts.MainGPU))
|
||||
}
|
||||
|
||||
const limitedMMProjOffloadMemory = 10 << 30
|
||||
const (
|
||||
// mmprojOffloadHeadroom leaves 1 GiB for backend buffers beyond projector weights.
|
||||
mmprojOffloadHeadroom = 1 << 30
|
||||
)
|
||||
|
||||
func appendMMProjArgs(params []string, launch llamaServerLaunchConfig) []string {
|
||||
if len(launch.projectors) == 0 {
|
||||
@@ -622,10 +626,10 @@ func (launch llamaServerLaunchConfig) mmprojOffloadDisabled() (bool, string) {
|
||||
if launch.forceNoMMProjOffload {
|
||||
return true, "startup-oom-retry"
|
||||
}
|
||||
return shouldDisableMMProjOffload(launch.opts, launch.gpus, launch.modelLayers)
|
||||
return shouldDisableMMProjOffload(launch.opts, launch.gpus, launch.modelLayers, launch.mmprojMemory)
|
||||
}
|
||||
|
||||
func shouldDisableMMProjOffload(opts api.Options, gpus []ml.DeviceInfo, modelLayers uint64) (bool, string) {
|
||||
func shouldDisableMMProjOffload(opts api.Options, gpus []ml.DeviceInfo, modelLayers, mmprojMemory uint64) (bool, string) {
|
||||
if opts.NumGPU == 0 {
|
||||
return true, "cpu-only"
|
||||
}
|
||||
@@ -633,6 +637,8 @@ func shouldDisableMMProjOffload(opts api.Options, gpus []ml.DeviceInfo, modelLay
|
||||
return true, "partial-text-offload"
|
||||
}
|
||||
|
||||
requiredMemory := mmprojMemory + mmprojOffloadHeadroom
|
||||
|
||||
for _, gpu := range gpus {
|
||||
if gpu.Integrated && gpu.Library != "Metal" {
|
||||
return true, "shared-memory-gpu"
|
||||
@@ -641,7 +647,7 @@ func shouldDisableMMProjOffload(opts api.Options, gpus []ml.DeviceInfo, modelLay
|
||||
if memory == 0 || (gpu.TotalMemory > 0 && gpu.TotalMemory < memory) {
|
||||
memory = gpu.TotalMemory
|
||||
}
|
||||
if memory > 0 && memory <= limitedMMProjOffloadMemory {
|
||||
if memory > 0 && memory < requiredMemory {
|
||||
return true, "limited-vram"
|
||||
}
|
||||
}
|
||||
@@ -649,6 +655,48 @@ func shouldDisableMMProjOffload(opts api.Options, gpus []ml.DeviceInfo, modelLay
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// mmprojMemoryRequirement is a stopgap until fit accounts for mmproj memory directly.
|
||||
func mmprojMemoryRequirement(modelPath string, f *ggml.GGML, projectors []string) (uint64, error) {
|
||||
if len(projectors) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
if projectors[0] == modelPath {
|
||||
if f == nil {
|
||||
return 0, errors.New("read inline mmproj metadata: missing model metadata")
|
||||
}
|
||||
var size uint64
|
||||
for _, prefix := range []string{"v.", "mm.", "a."} {
|
||||
for _, tensor := range f.Tensors().Items(prefix) {
|
||||
size += tensor.Size()
|
||||
}
|
||||
}
|
||||
if size == 0 {
|
||||
return 0, errors.New("read inline mmproj metadata: no projector tensors found")
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
file, err := os.Open(projectors[0])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read mmproj metadata %q: %w", projectors[0], err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
projector, err := ggml.Decode(file, 1024)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read mmproj metadata %q: %w", projectors[0], err)
|
||||
}
|
||||
var size uint64
|
||||
for _, tensor := range projector.Tensors().Items() {
|
||||
size += tensor.Size()
|
||||
}
|
||||
if size == 0 {
|
||||
return 0, fmt.Errorf("read mmproj metadata %q: no projector tensors found", projectors[0])
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func appendJinjaArgs(params []string, config LlamaServerConfig) []string {
|
||||
if config.DisableJinja {
|
||||
// Go-rendered chat paths send already-rendered prompts through completion
|
||||
@@ -752,6 +800,10 @@ func NewLlamaServerRunner(
|
||||
compatClipArches[arch] {
|
||||
projectors = []string{modelPath}
|
||||
}
|
||||
mmprojMemory, err := mmprojMemoryRequirement(modelPath, f, projectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.DraftModelPath == "" && hasMTPDraft(f) {
|
||||
config.EnableMTP = true
|
||||
}
|
||||
@@ -771,19 +823,20 @@ func NewLlamaServerRunner(
|
||||
serverEnvs["LLAMA_MEDIA_MARKER"] = mediaMarker
|
||||
|
||||
launch := llamaServerLaunchConfig{
|
||||
modelPath: modelPath,
|
||||
modelArch: arch,
|
||||
projectors: slices.Clone(projectors),
|
||||
modelLayers: f.KV().BlockCount() + 1,
|
||||
adapters: slices.Clone(adapters),
|
||||
opts: opts,
|
||||
numParallel: numParallel,
|
||||
kvCacheType: kvCacheType,
|
||||
embedding: isEmbedding,
|
||||
config: config,
|
||||
gpus: slices.Clone(gpus),
|
||||
gpuLibs: slices.Clone(gpuLibs),
|
||||
extraEnvs: cloneStringMap(serverEnvs),
|
||||
modelPath: modelPath,
|
||||
modelArch: arch,
|
||||
projectors: slices.Clone(projectors),
|
||||
mmprojMemory: mmprojMemory,
|
||||
modelLayers: f.KV().BlockCount() + 1,
|
||||
adapters: slices.Clone(adapters),
|
||||
opts: opts,
|
||||
numParallel: numParallel,
|
||||
kvCacheType: kvCacheType,
|
||||
embedding: isEmbedding,
|
||||
config: config,
|
||||
gpus: slices.Clone(gpus),
|
||||
gpuLibs: slices.Clone(gpuLibs),
|
||||
extraEnvs: cloneStringMap(serverEnvs),
|
||||
}
|
||||
|
||||
s := &llamaServerRunner{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -1961,13 +1962,14 @@ func TestAppendMMProjArgs(t *testing.T) {
|
||||
cpuOpts.NumGPU = 0
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
projectors []string
|
||||
opts api.Options
|
||||
gpus []ml.DeviceInfo
|
||||
modelLayers uint64
|
||||
retry bool
|
||||
want []string
|
||||
name string
|
||||
projectors []string
|
||||
opts api.Options
|
||||
gpus []ml.DeviceInfo
|
||||
mmprojMemory uint64
|
||||
modelLayers uint64
|
||||
retry bool
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "no projector leaves args unchanged",
|
||||
@@ -1975,69 +1977,86 @@ func TestAppendMMProjArgs(t *testing.T) {
|
||||
want: []string{"base"},
|
||||
},
|
||||
{
|
||||
name: "large discrete gpu keeps projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf"},
|
||||
name: "large discrete gpu keeps projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf"},
|
||||
},
|
||||
{
|
||||
name: "small discrete gpu disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, TotalMemory: 8 << 30}},
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
name: "small discrete gpu keeps projector offload when projector fits",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "ROCm"}, FreeMemory: 7900 << 20, TotalMemory: 8 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf"},
|
||||
},
|
||||
{
|
||||
name: "integrated rocm gpu disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "ROCm"}, Integrated: true, FreeMemory: 32 << 30}},
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
name: "tight discrete gpu disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 1500 << 20, TotalMemory: 8 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
},
|
||||
{
|
||||
name: "integrated metal gpu keeps projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "Metal"}, Integrated: true, FreeMemory: 32 << 30}},
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf"},
|
||||
name: "integrated rocm gpu disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "ROCm"}, Integrated: true, FreeMemory: 32 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
},
|
||||
{
|
||||
name: "cpu only request disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: cpuOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
name: "integrated metal gpu keeps projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "Metal"}, Integrated: true, FreeMemory: 32 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf"},
|
||||
},
|
||||
{
|
||||
name: "partial text offload disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: partialOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
name: "cpu only request disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: cpuOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
},
|
||||
{
|
||||
name: "explicit full text offload keeps projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: fullOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf"},
|
||||
name: "partial text offload disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: partialOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
},
|
||||
{
|
||||
name: "startup oom retry disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
modelLayers: 81,
|
||||
retry: true,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
name: "explicit full text offload keeps projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: fullOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
want: []string{"base", "--mmproj", "model.gguf"},
|
||||
},
|
||||
{
|
||||
name: "startup oom retry disables projector offload",
|
||||
projectors: []string{"model.gguf"},
|
||||
opts: defaultOpts,
|
||||
gpus: []ml.DeviceInfo{{DeviceID: ml.DeviceID{Library: "CUDA"}, FreeMemory: 24 << 30}},
|
||||
mmprojMemory: 933 << 20,
|
||||
modelLayers: 81,
|
||||
retry: true,
|
||||
want: []string{"base", "--mmproj", "model.gguf", "--no-mmproj-offload"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2046,6 +2065,7 @@ func TestAppendMMProjArgs(t *testing.T) {
|
||||
got := appendMMProjArgs([]string{"base"}, llamaServerLaunchConfig{
|
||||
modelPath: "model.gguf",
|
||||
projectors: tt.projectors,
|
||||
mmprojMemory: tt.mmprojMemory,
|
||||
opts: tt.opts,
|
||||
gpus: tt.gpus,
|
||||
modelLayers: tt.modelLayers,
|
||||
@@ -2058,6 +2078,45 @@ func TestAppendMMProjArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMMProjMemoryRequirement(t *testing.T) {
|
||||
if got, err := mmprojMemoryRequirement("model.gguf", nil, nil); err != nil || got != 0 {
|
||||
t.Fatalf("no projector memory = %d, %v; want 0, nil", got, err)
|
||||
}
|
||||
|
||||
modelPath, model := writeTestGGML(t, ggml.KV{"general.architecture": "gemma3"}, []*ggml.Tensor{
|
||||
testGGMLTensor("blk.0.attn_q.weight", ggml.TensorTypeF32, []uint64{4}),
|
||||
testGGMLTensor("v.patch_embd.weight", ggml.TensorTypeF16, []uint64{16}),
|
||||
testGGMLTensor("mm.0.weight", ggml.TensorTypeF32, []uint64{8}),
|
||||
testGGMLTensor("a.encoder.weight", ggml.TensorTypeF32, []uint64{2}),
|
||||
})
|
||||
|
||||
wantInline := uint64(16*2 + 8*4 + 2*4)
|
||||
if got, err := mmprojMemoryRequirement(modelPath, model, []string{modelPath}); err != nil || got != wantInline {
|
||||
t.Fatalf("inline mmproj memory = %d, %v; want %d, nil", got, err, wantInline)
|
||||
}
|
||||
|
||||
projectorPath, _ := writeTestGGML(t, ggml.KV{"general.architecture": "clip"}, []*ggml.Tensor{
|
||||
testGGMLTensor("vision.weight", ggml.TensorTypeF16, []uint64{32}),
|
||||
testGGMLTensor("audio.weight", ggml.TensorTypeF32, []uint64{4}),
|
||||
})
|
||||
wantProjector := uint64(32*2 + 4*4)
|
||||
if got, err := mmprojMemoryRequirement(modelPath, model, []string{projectorPath}); err != nil || got != wantProjector {
|
||||
t.Fatalf("projector file memory = %d, %v; want %d, nil", got, err, wantProjector)
|
||||
}
|
||||
|
||||
if _, err := mmprojMemoryRequirement(modelPath, nil, []string{modelPath}); err == nil {
|
||||
t.Fatal("inline mmproj with nil model error = nil, want error")
|
||||
}
|
||||
if _, err := mmprojMemoryRequirement(modelPath, model, []string{filepath.Join(t.TempDir(), "missing.gguf")}); err == nil {
|
||||
t.Fatal("missing projector error = nil, want error")
|
||||
}
|
||||
|
||||
emptyProjectorPath, _ := writeTestGGML(t, ggml.KV{"general.architecture": "clip"}, nil)
|
||||
if _, err := mmprojMemoryRequirement(modelPath, model, []string{emptyProjectorPath}); err == nil {
|
||||
t.Fatal("empty projector error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendJinjaArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -3145,11 +3204,18 @@ func TestFindLlamaServer(t *testing.T) {
|
||||
func loadTestGGML(t *testing.T, kv ggml.KV) *ggml.GGML {
|
||||
t.Helper()
|
||||
|
||||
_, model := writeTestGGML(t, kv, nil)
|
||||
return model
|
||||
}
|
||||
|
||||
func writeTestGGML(t *testing.T, kv ggml.KV, tensors []*ggml.Tensor) (string, *ggml.GGML) {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "*.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ggml.WriteGGUF(f, kv, nil); err != nil {
|
||||
if err := ggml.WriteGGUF(f, kv, tensors); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
@@ -3160,7 +3226,17 @@ func loadTestGGML(t *testing.T, kv ggml.KV) *ggml.GGML {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return model
|
||||
return f.Name(), model
|
||||
}
|
||||
|
||||
func testGGMLTensor(name string, kind ggml.TensorType, shape []uint64) *ggml.Tensor {
|
||||
tensor := &ggml.Tensor{
|
||||
Name: name,
|
||||
Kind: uint32(kind),
|
||||
Shape: shape,
|
||||
}
|
||||
tensor.WriterTo = bytes.NewReader(make([]byte, tensor.Size()))
|
||||
return tensor
|
||||
}
|
||||
|
||||
// fakeRunningCmd returns an exec.Cmd that looks like it's still running
|
||||
|
||||
Reference in New Issue
Block a user