internal/autogen/liveoffload.go
Rama main · réplica de lectura del repositorio original.
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, "+")
}