package autogen // liveoffload.go re-derives a model's GPU/CPU layer placement from the VRAM // free RIGHT NOW, at spawn time, instead of trusting the figure baked into the // generated config. The config's -ngl/--n-cpu-moe are computed once (at generate // time or startup autoVram); if another app grabs VRAM afterwards, loading a // model with those stale flags can OOM. This adjusts the emitted argv against a // live free-VRAM reading just before exec. import ( "fmt" "os" "path/filepath" "strconv" "strings" ) // LiveOffloadArgs adjusts an emitted llama-server arg vector for the VRAM free // right now (freeGB). When live free VRAM is tighter than the config assumed it // pushes more layers/experts to CPU (raising --n-cpu-moe / lowering -ngl) so the // load won't OOM. It only ever offloads MORE than the baked flags, never less, // so a config that fits live VRAM (or a hand-pinned cpuOffload) is left untouched. // // If the model can't fit even at the planner's max offload it returns an error, // so the caller refuses the spawn with a clear message instead of OOM-crashing. // // It fails open: a non-llama cmd, an unreadable gguf, no GPU telemetry // (freeOK=false), or an estimate error all return args unchanged. func LiveOffloadArgs(s Settings, args []string, freeGB float64, freeOK bool, logf func(string)) ([]string, error) { if !freeOK { return args, nil // no reading -> trust the baked plan } // sd-server (image gen) has no -m gguf and no -ngl; its only VRAM knob is // --max-vram, a runtime streaming budget. Pin it to live free VRAM minus a // headroom pad so a generation's peak (VAE decode, Kontext ref tokens) can't // overcommit shared VRAM and hang the desktop. Only ever TIGHTEN below the // baked value (never raise): ample free leaves the safe baked budget alone, // and a hand-pinned vramTargetGB stays respected. if maxVram, idx := argVal(args, "--max-vram"); idx >= 0 { return liveMaxVram(args, idx, maxVram, freeGB, logf), nil } model, _ := argVal(args, "-m", "--model") lower := strings.ToLower(model) // SAM (sam3_server) segmentation: model is *.ggml. Always force CPU. The // Vulkan backend on RX 7900 XTX numerically corrupts SAM inference — both the // PCS (text) detector scores AND the PVS (box/point) masks come back garbage // (scores collapse to ~0.0003, no object found), while CPU gives correct // results. SAM is tiny and coexists with a loaded LLM/image model (never // evicted), so the CPU cost (~14s/segment) is acceptable and the only backend // that actually works here. An explicit --no-gpu (user extraArgs) is a no-op. // ponytail: force CPU for all .ggml; revisit if a non-broken GPU backend // (CUDA, or a fixed Vulkan/ROCm build) is ever wired for SAM. if strings.HasSuffix(lower, ".ggml") { if hasFlag(args, "--no-gpu") { return args, nil } out := append(append([]string(nil), args...), "--no-gpu") if logf != nil { logf("dynoffload: sam -> --no-gpu (Vulkan SAM inference is numerically broken; CPU only)") } return out, nil } if !strings.HasSuffix(lower, ".gguf") { return args, nil // not a llama.cpp model load (sd-server, custom cmd, …) } if dev, i := argVal(args, "--device", "-dev"); i >= 0 && strings.EqualFold(dev, "none") { return args, nil // CPU-device pin: no GPU placement to retune } nglStr, nglIdx := argVal(args, "-ngl", "--n-gpu-layers", "--gpu-layers") if nglIdx < 0 { return args, nil // no offload knob to tune } bakedNgl, _ := strconv.Atoi(nglStr) ncStr, ncIdx := argVal(args, "--n-cpu-moe") bakedNcpu := 0 if ncIdx >= 0 { bakedNcpu, _ = strconv.Atoi(ncStr) } meta, err := ReadGgufMetadataCached(model) if err != nil { if logf != nil { logf(fmt.Sprintf("dynoffload: gguf read failed (%v); using baked flags", err)) } return args, nil } // Budget = the TIGHTER of live free and the configured target. Live free is a // snapshot, and the other GPU clients on a desktop box are not static: dwm // alone swings by a GB or more with monitor/HDR/compositing load, and Discord, // Steam, a VR runtime or a browser can each take a few hundred MB AFTER the // model has already claimed everything the snapshot showed. Planning against // 100% of that snapshot is what makes a load that fit yesterday spill into // shared memory today: nothing is left for the growth, so the driver silently // demotes part of the model and throughput collapses. // // Capping at TargetVramGB makes the setting mean what it says on the load path // too (it used to bind only at generate time), so the split is a deliberate // decision instead of a race with whatever the desktop happened to hold at // spawn. Free still wins when it is the smaller of the two — a target above // what the card actually has left must not talk us into an OOM. budgetGB := liveBudgetGB(s, freeGB) if logf != nil && budgetGB < freeGB { logf(fmt.Sprintf("dynoffload: budget %.1fGB (targetVramGB, free=%.1fGB)", budgetGB, freeGB)) } in := EstimateInput{ Ctx: atoiFlag(args, "-c", "--ctx-size"), KvK: flagStr(args, "-ctk", "--cache-type-k"), KvV: flagStr(args, "-ctv", "--cache-type-v"), KvInRam: hasFlag(args, "--no-kv-offload"), // Draft cache type. An argv without -ctkd really does run the draft // context on f16 (llama's own default is independent of -ctk), so pin f16 // rather than letting the sizer assume the emitter's matched default. KvKDraft: flagStrDef(args, "f16", "-ctkd", "--cache-type-k-draft"), KvVDraft: flagStrDef(args, "f16", "-ctvd", "--cache-type-v-draft"), Spec: specTypes(args), RopeScaling: flagStr(args, "--rope-scaling"), TargetVramGB: budgetGB, // EstimatePlan subtracts overhead via s // The compute buffer is sized by the physical batch, so the live guard has // to charge the -ub the argv actually launches with rather than re-deriving // the auto pick and mis-sizing a model with a pinned ub. Ub: atoiFlag(args, "-ub", "--ubatch-size"), } if c, ok := atoiFlagOK(args, "--ctx-checkpoints"); ok { in.CtxCheckpoints = &c } else { // No flag in the argv means llama-server's own 32, not our arch default. c := LlamaDefaultCtxCheckpoints in.CtxCheckpoints = &c } // Spacing scales each checkpoint's global-KV term, so the live re-estimate has // to charge the same step the baked argv runs with — and an argv with no -cms // runs at llama's 8192, not at the arch default we would have emitted. if step := atoiFlag(args, "-cms", "--checkpoint-min-step"); step > 0 { in.CheckpointMinStep = step } else { in.CheckpointMinStep = LlamaDefaultCheckpointMinStep } // A "-vision" twin loads a CLIP projector via --mmproj. The model gguf (-m) // carries no vision info, so EstimatePlan is projector-blind; charge the // projector's weights + CLIP compute reserve here so the live guard sizes the // twin like the baked plan did, instead of under-offloading and leaving too // little free VRAM once the projector + image buffers load. // ... unless the argv also carries --no-mmproj-offload, which parks the CLIP // tower in RAM. Then the projector costs no VRAM at all and charging it would // make the guard offload text layers the baked plan meant to keep on the GPU. if mm, i := argVal(args, "--mmproj"); i >= 0 && !hasFlag(args, "--no-mmproj-offload") { if fi, statErr := os.Stat(mm); statErr == nil { in.MmprojGB = MmprojVramGB(mm, float64(fi.Size())/gib, s) } else if logf != nil { logf(fmt.Sprintf("dynoffload: --mmproj stat failed (%v); projector VRAM uncharged", statErr)) } } // A separate draft gguf (-md: MTP sidecar or any DFlash drafter) has real // weights on disk; charge its actual size instead of the small baked-in-MTP // pad so a big drafter doesn't get under-charged here. if md, i := argVal(args, "-md"); i >= 0 { if fi, statErr := os.Stat(md); statErr == nil { in.DraftGB = float64(fi.Size()) / gib // This file is on the launched cmd line, so it loads regardless of its // name. Tag it with the kind the spec chain drafts with so the sizer's // kind gate (matchedDraftSizeGB) keeps charging it. in.DraftKind = specDraftKind(in.Spec) } else if logf != nil { logf(fmt.Sprintf("dynoffload: -md stat failed (%v); draft VRAM under-charged", statErr)) } } res, err := EstimatePlan(s, meta, in) if err != nil { if logf != nil { logf(fmt.Sprintf("dynoffload: estimate failed (%v); using baked flags", err)) } return args, nil } // EstVramGB already folds in the overhead/compute reserve, so it IS the live // footprint. The planner already maxed out offload to fit; if it still can't, // even a fully CPU-offloaded load would OOM -> refuse. if res.EstVramGB > freeGB { return nil, fmt.Errorf("insufficient VRAM to load %s: needs ~%.1fGB at max CPU offload but only %.1fGB free - close other GPU apps and retry", filepath.Base(model), res.EstVramGB, freeGB) } // Context is the cheaper currency. When the live reading falls a little short // of what the baked plan assumed, the planner pays for the difference in // PLACEMENT: a dense model drops a layer - its weights AND its KV to RAM, // crossed on every token - to reclaim what a few thousand tokens of window // would have covered. Shrink the window first and keep the baked placement if // that fits. Only the argv is rewritten, so the config keeps its full window // for the next spawn, when the desktop may have handed the VRAM back. if res.Ngl < bakedNgl || res.NCpuMoe > bakedNcpu { if _, ctxIdx := argVal(args, "-c", "--ctx-size"); ctxIdx >= 0 { if ctx, trimmed, ok := trimCtxForPlacement(s, meta, in, argSlots(args), bakedNgl, bakedNcpu); ok { if logf != nil { logf(fmt.Sprintf("dynoffload: free=%.1fGB -> -c %d->%d instead of -ngl %d->%d (est %.1fGB)", freeGB, in.Ctx, ctx, bakedNgl, res.Ngl, trimmed.EstVramGB)) } args = rewriteCtx(args, ctxIdx, ctx) in.Ctx, res = ctx, trimmed } } } // Admission floor. Without one, multi-resident loading always "succeeds": // the sizer just pushes the new model's layers/experts to CPU until it fits // whatever VRAM the already-resident models left over, and the box ends up // with everything loaded and everything crawling. Refuse instead — but only // when the BAKED plan was itself above the floor, so a model deliberately // configured to run mostly on CPU (a 35B MoE on a small card) still loads. if s.MinGpuFraction > 0 { live := gpuResidentFraction(meta, res.Ngl, res.NCpuMoe) baked := gpuResidentFraction(meta, bakedNgl, bakedNcpu) if live < s.MinGpuFraction && baked >= s.MinGpuFraction { return nil, fmt.Errorf("refusing to load %s: only %.0f%% of it would fit on the GPU right now (%.1fGB free, floor %.0f%%) - it would run at CPU speed; free VRAM or unload another model first", filepath.Base(model), live*100, freeGB, s.MinGpuFraction*100) } } // Only intervene when the live plan offloads MORE than the baked one. Ample // VRAM (res keeps everything on GPU) or a hand-pinned config is left as-is. if res.NCpuMoe <= bakedNcpu && res.Ngl >= bakedNgl { // Log the no-op too. Without this the guard is only visible when it fires, // so "the model loaded a layer short" can't be attributed: there's no way to // tell a low live reading from an over-conservative estimate without knowing // both numbers on a load that went fine. if logf != nil { logf(fmt.Sprintf("dynoffload: free=%.1fGB est=%.1fGB -> baked plan kept (-ngl %d --n-cpu-moe %d)", freeGB, res.EstVramGB, bakedNgl, bakedNcpu)) } return retuneTensorSplit(s, args, res.FixedGB, logf), nil } out := retuneTensorSplit(s, rewriteOffload(args, nglIdx, ncIdx, res.Ngl, res.NCpuMoe), res.FixedGB, logf) if logf != nil { logf(fmt.Sprintf("dynoffload: free=%.1fGB -> -ngl %d->%d --n-cpu-moe %d->%d (est %.1fGB)", freeGB, bakedNgl, res.Ngl, bakedNcpu, res.NCpuMoe, res.EstVramGB)) } return out, nil } // retuneTensorSplit rewrites a baked --tensor-split (and its --main-gpu) from // the device set as it looks NOW. Everything else in this file adjusts HOW MUCH // of a model goes to the GPU; this decides WHERE it lands. // // The baked ratio was computed at generate time from each card's idle free VRAM. // By spawn time another model, or a game, can be sitting on one card only, and // the stale ratio then still sends that card its full share of the layers: the // total fits the pooled budget, the load OOMs anyway. Re-deriving from the live // per-device reading is the only way the pooled sizing stays honest. // // mainFixedGB is the plan's non-splittable cost (compute buffer, CUDA context, // projector, headroom). llama.cpp puts all of it on --main-gpu, so it is charged // to that device before the ratio is normalised, exactly as generate does. // // A no-op when the argv has no --tensor-split (single-GPU box, or a hand-pinned // config), or when s.Gpus is empty because the caller had no live telemetry: the // baked ratio is then still the best guess available. func retuneTensorSplit(s Settings, args []string, mainFixedGB float64, logf func(string)) []string { baked, idx := argVal(args, "--tensor-split", "-ts") if idx < 0 { return args } set := s.GpuSetOrEmpty() if !set.Multi() { return args } split := set.TensorSplit(mainFixedGB) if len(split) < 2 { return args } main := set.MainIndex() out := append([]string(nil), args...) // --main-gpu and --tensor-split are positions in whatever device list the // launch runs with. With no --device that list is the backend's own, which // the telemetry index has always stood in for, and the split is already in // set order. // // With --device, generate pinned the list AND its order: main last, because // that is the position llama.cpp gives the non-splittable output weight // under -sm layer (see GpuSet.MainLastOrder). The live main device need not // be the one generate picked, so the list is re-derived here rather than // trusted, and the freshly computed split is permuted to match it. // // If the devices cannot be named now (a probe that worked at generate time // and not at spawn), the baked list stays and NOTHING is rewritten: a split // written in set order against a list in main-last order would hand each // card the other's ratio, which is worse than the stale ratio it replaced. if _, dev := argVal(out, "--device", "-dev"); dev >= 0 { devs, order := DeviceFlagFor(s.ServerExe, set, main) if devs == "" { return args } out[dev] = devs split = PermuteSplit(split, order) main = len(order) - 1 } next := FormatSplit(split) out[idx] = next if _, mi := argVal(out, "--main-gpu", "-mg"); mi >= 0 { out[mi] = strconv.Itoa(main) } if logf != nil && next != baked { logf(fmt.Sprintf("dynoffload: --tensor-split %s -> %s (main gpu %d, %.1fGB fixed)", baked, next, main, mainFixedGB)) } return out } // liveBudgetGB is the VRAM the spawn-time sizer is allowed to plan into: the // tighter of the live free reading and the configured TargetVramGB. See the // call site for why the raw free snapshot is not a safe budget on a desktop. // TargetVramGB <= 0 (unset) leaves the free reading as the only bound. func liveBudgetGB(s Settings, freeGB float64) float64 { if s.TargetVramGB > 0 && s.TargetVramGB < freeGB { return s.TargetVramGB } return freeGB } // gpuResidentFraction estimates how much of a model's weight ends up on the GPU // under a given placement, as 0..1. // // Dense: the offloaded layer share (-ngl of the block count). MoE: -ngl is 99 // and the real lever is --n-cpu-moe, which moves N blocks' EXPERT tensors to // CPU — so the CPU share is the expert-weight fraction scaled by N/blocks, and // the dense trunk stays on the GPU. Unknown block count => 1 (no opinion, which // keeps the floor from firing on a model it can't measure). func gpuResidentFraction(meta Metadata, ngl, ncpuMoe int) float64 { blocks := int(meta.BlockCount) if blocks <= 0 { return 1 } if meta.IsMoE && ncpuMoe > 0 { share := effectiveShare(meta, genMoeShareFor) onCPU := share * float64(min(ncpuMoe, blocks)) / float64(blocks) return 1 - onCPU } if ngl >= blocks { return 1 } if ngl <= 0 { return 0 } return float64(ngl) / float64(blocks) } // imageVramHeadroomGB is the VRAM kept free above sd-server's --max-vram budget, // so the desktop compositor + un-modeled spikes have room and can't hard-hang // Windows. imageVramFloorGB is the smallest budget we'll set: below it sd.cpp // just pages nearly everything from RAM (slow but safe), never a load failure. const ( imageVramHeadroomGB = 1.0 imageVramFloorGB = 0.5 ) // liveMaxVram rewrites the --max-vram value (at index idx) to freeGB minus the // headroom pad, but only when that is TIGHTER than the baked budget. Ample free // VRAM, an unparseable value, or a baked budget already under the live ceiling // all return args unchanged. // // What this can and cannot correct for is worth being precise about, because it // looks like a safety net and is only half of one. --max-vram is sd.cpp's // graph-cut budget, and the baked value already has this model's own resident // weights subtracted (see graphBudget). freeGB is measured at LAUNCH, and // sd-server loads its params lazily (--eager-load defaults off), so at that // moment this model's weights are not in VRAM yet and the reading is flattering. // // So this corrects for OTHER tenants of the card, and never for the model being // launched. A baked budget that forgot its own weights would sail straight past // the tighten-only test here and OOM at first sample, which is exactly the shape // of the LTX-2.5 failure that put graphBudget in the emitter. func liveMaxVram(args []string, idx int, baked string, freeGB float64, logf func(string)) []string { bakedGB, err := strconv.ParseFloat(baked, 64) if err != nil { return args // leave a hand-written non-numeric budget alone } budget := freeGB - imageVramHeadroomGB if budget >= bakedGB { return args // enough headroom at the baked budget; don't loosen it } if budget < imageVramFloorGB { budget = imageVramFloorGB } out := append([]string(nil), args...) out[idx] = strconv.FormatFloat(budget, 'f', 1, 64) if logf != nil { logf(fmt.Sprintf("dynoffload: sd --max-vram %s->%s (free=%.1fGB, %.1fGB headroom)", baked, out[idx], freeGB, imageVramHeadroomGB)) } return out } // rewriteOffload returns a copy of args with -ngl set to newNgl and --n-cpu-moe // set to newNcpu (appending the flag when it wasn't present and the value is // nonzero). nglIdx is the index of the -ngl value; ncIdx the --n-cpu-moe value // index, or -1 when absent. func rewriteOffload(args []string, nglIdx, ncIdx, newNgl, newNcpu int) []string { out := append([]string(nil), args...) out[nglIdx] = strconv.Itoa(newNgl) switch { case ncIdx >= 0: out[ncIdx] = strconv.Itoa(newNcpu) case newNcpu > 0: out = append(out, "--n-cpu-moe", strconv.Itoa(newNcpu)) } return out } // ctxTrimFloorFrac bounds how far the guard may shrink the launch window to buy // back a layer. Below ~60% of the configured window the window itself is the // thing the user notices (a conversation that used to fit now doesn't), and // spilling a layer becomes the better trade. const ctxTrimFloorFrac = 0.6 // trimCtxForPlacement looks for the largest context <= the baked one that keeps // the BAKED placement under the live budget, stepping down one 4096-token block // per slot at a time. Returns the trimmed TOTAL -c value and its estimate. // // -c is total context (per-slot x --parallel, because --kv-unified shares one KV // pool), so the step is scaled by the slot count: anything else would leave slots // holding a non-multiple of the 4096 block the sizer rounds to, and the next // probe would re-round to the same place and loop without progress. func trimCtxForPlacement(s Settings, meta Metadata, in EstimateInput, slots, bakedNgl, bakedNcpu int) (int, EstimateResult, bool) { if in.Ctx <= 0 { return 0, EstimateResult{}, false } if slots < 1 { slots = 1 } step := 4096 * slots floor := int(float64(in.Ctx) * ctxTrimFloorFrac) probe := in for ctx := in.Ctx - step; ctx >= step && ctx >= floor; ctx -= step { probe.Ctx = ctx res, err := EstimatePlan(s, meta, probe) if err != nil { return 0, EstimateResult{}, false } if res.Ngl >= bakedNgl && res.NCpuMoe <= bakedNcpu { return ctx, res, true } } return 0, EstimateResult{}, false } // rewriteCtx is rewriteOffload's twin for -c: copy, assign by index. The flag is // always present when this is called (the caller got the index from argVal), so // there is no append path. func rewriteCtx(args []string, ctxIdx, ctx int) []string { out := append([]string(nil), args...) out[ctxIdx] = strconv.Itoa(ctx) return out } // argSlots is the launched --parallel value (1 when unset), i.e. how many slots // the total -c is divided across. func argSlots(args []string) int { if n := atoiFlag(args, "-np", "--parallel"); n > 0 { return n } return 1 } // argVal returns the value following the first matching flag and the index of // that value (-1 if absent). Args are already shlex-split, so flag and value are // adjacent tokens. func argVal(args []string, names ...string) (string, int) { for i, a := range args { for _, n := range names { if a == n && i+1 < len(args) { return args[i+1], i + 1 } } } return "", -1 } func flagStr(args []string, names ...string) string { v, _ := argVal(args, names...) return v } // flagStrDef is flagStr with a fallback for a flag the argv omits, for the cases // where "absent" means a specific llama default rather than "let us choose". func flagStrDef(args []string, def string, names ...string) string { if v, idx := argVal(args, names...); idx >= 0 && v != "" { return v } return def } func atoiFlag(args []string, names ...string) int { n, _ := atoiFlagOK(args, names...) return n } func atoiFlagOK(args []string, names ...string) (int, bool) { v, idx := argVal(args, names...) if idx < 0 { return 0, false } n, err := strconv.Atoi(v) return n, err == nil } func hasFlag(args []string, name string) bool { for _, a := range args { if a == name { return true } } return false } // specDraftKind reports which draft sidecar kind a spec chain loads via -md // ("mtp"/"dflash", "" for a model-less chain like ngram-mod). func specDraftKind(spec string) string { switch { case specHas(spec, "draft-dflash"): return "dflash" case specHas(spec, "draft-mtp"): return "mtp" } return "" } // specTypes joins every --spec-type value with "+", matching the form the // generator emits and that specHas() scans. func specTypes(args []string) string { var specs []string for i, a := range args { if a == "--spec-type" && i+1 < len(args) { specs = append(specs, args[i+1]) } } return strings.Join(specs, "+") }