Files
LocalAI/pkg/vrambudget/budget.go
LocalAI [bot] 8cec22c3b7 feat(vram): per-node VRAM allocation budget (LOCALAI_VRAM_BUDGET) (#10833)
* feat(vram): add vrambudget primitive for per-node VRAM caps

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): apply default VRAM budget in xsysinfo aggregate getters

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): wire LOCALAI_VRAM_BUDGET flag to xsysinfo default budget

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): persist VRAM budget via runtime settings with live apply

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(vram): reset process-global VRAM budget after runtime-settings spec

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): add VRAM budget field to Settings page

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): store and enforce per-node VRAM budget in the node registry

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): apply per-node VRAM budget in router hardware defaults

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): report worker VRAM budget in node registration

The distributed worker now reports its operator-set VRAM budget string
(LOCALAI_VRAM_BUDGET) to the server on registration. The worker keeps
reporting RAW total/available VRAM and never sets the xsysinfo
process-global budget (that stays standalone-only); the server resolves
and enforces the budget uniformly (Task 6).

Also closes a Task 6 gap: on re-registration, a struct Updates zero-skips
an empty budget, so a worker that dropped LOCALAI_VRAM_BUDGET left the
stale cap in place. For non-admin-override nodes the budget columns are
now force-written (map Updates) even when empty, so removing the env var
clears the cap; admin overrides are preserved unchanged.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* style(vram): drop em dash from worker-clear comment

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): add node VRAM budget admin endpoints

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): add node VRAM budget control to the node UI

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(vram): expose set_node_vram_budget MCP admin tool

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(vram): document LOCALAI_VRAM_BUDGET and node VRAM budget UI

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(vram): avoid double-applying VRAM budget in GetResourceAggregateInfo

The GPU-branch aggregate returned by GetResourceInfo is sourced from
GetGPUAggregateInfo, which already caps total/free/used against the
process-wide VRAM budget. GetResourceAggregateInfo then applied the
budget a second time. For an absolute budget this is idempotent, but for
a percentage budget b.Apply resolves the ceiling as a fraction of its
input total, so a second pass yields P*(P*T) instead of P*T and distorts
UsagePercent (read by the memory reclaimer in pkg/model/watchdog.go).

Remove the redundant second application so the budget is applied exactly
once, against the raw physical totals, upstream in GetGPUAggregateInfo.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(vram): implement SetNodeVRAMBudget on mcp assistant test stub

The LocalAIClient interface gained SetNodeVRAMBudget; the stubClient in
core/http/endpoints/mcp used by the assistant tests is a separate
implementer and needs the method too (broke golangci-lint typecheck and
both test jobs).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-15 09:58:45 +02:00

159 lines
4.7 KiB
Go

// Package vrambudget parses an operator-set cap on how much VRAM LocalAI may
// use for model allocation on a node, and applies it as a hard ceiling.
//
// A budget is expressed as either a percentage of detected VRAM ("80%", "0.8")
// or an absolute amount ("12GB", "12GiB", raw bytes). The zero value is "no
// cap" so every existing deployment is unaffected until a budget is set.
package vrambudget
import (
"fmt"
"math"
"strconv"
"strings"
)
// Budget is an optional cap on allocatable VRAM. The zero value means no cap.
type Budget struct {
fraction float64 // (0,1] when percentage form; 0 otherwise
absolute uint64 // bytes when absolute form; 0 otherwise
}
// decimal (1000-based) and binary (1024-based) size suffixes, longest first so
// "GiB" is matched before "GB"/"B".
var sizeSuffixes = []struct {
suffix string
mult uint64
}{
{"KIB", 1 << 10}, {"MIB", 1 << 20}, {"GIB", 1 << 30}, {"TIB", 1 << 40},
{"KB", 1000}, {"MB", 1000 * 1000}, {"GB", 1000 * 1000 * 1000}, {"TB", 1000 * 1000 * 1000 * 1000},
{"B", 1},
}
// Parse accepts "", "80%", "0.8", "1.0", "12GB", "12GiB", "12000MB", raw bytes.
// Empty / zero forms return an unset Budget with no error. A percentage above
// 100% (a cap that would raise VRAM) is a config error; an absolute value above
// physical VRAM is harmless and clamped later in Apply.
func Parse(s string) (Budget, error) {
s = strings.TrimSpace(s)
if s == "" {
return Budget{}, nil
}
upper := strings.ToUpper(s)
// Percentage form: trailing '%'.
if strings.HasSuffix(upper, "%") {
num := strings.TrimSpace(strings.TrimSuffix(upper, "%"))
v, err := strconv.ParseFloat(num, 64)
if err != nil {
return Budget{}, fmt.Errorf("invalid vram budget percentage %q: %w", s, err)
}
return fromFraction(v/100.0, s)
}
// Absolute form: any known size suffix.
for _, sfx := range sizeSuffixes {
if strings.HasSuffix(upper, sfx.suffix) {
num := strings.TrimSpace(strings.TrimSuffix(upper, sfx.suffix))
v, err := strconv.ParseFloat(num, 64)
if err != nil || v < 0 {
return Budget{}, fmt.Errorf("invalid vram budget %q", s)
}
return Budget{absolute: uint64(v * float64(sfx.mult))}, nil
}
}
// Bare number: (0,1] is a fraction, anything else is absolute bytes.
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return Budget{}, fmt.Errorf("invalid vram budget %q", s)
}
if v < 0 {
return Budget{}, fmt.Errorf("invalid vram budget %q: negative", s)
}
if v > 0 && v <= 1 {
return fromFraction(v, s)
}
// A bare value above 1 is a byte count and must be whole: a fractional
// value >1 is neither a valid fraction (>100%) nor a sensible byte amount.
if v != math.Trunc(v) {
return Budget{}, fmt.Errorf("invalid vram budget %q: out of range", s)
}
return Budget{absolute: uint64(v)}, nil
}
func fromFraction(f float64, orig string) (Budget, error) {
if f <= 0 {
return Budget{}, nil // 0% == no cap
}
if f > 1 {
return Budget{}, fmt.Errorf("vram budget %q exceeds 100%%", orig)
}
return Budget{fraction: f}, nil
}
// IsSet reports whether a cap is configured.
func (b Budget) IsSet() bool { return b.fraction > 0 || b.absolute > 0 }
// Ceiling resolves the budget to an absolute byte ceiling against detectedTotal,
// clamped to detectedTotal so an over-large absolute budget can't fabricate
// VRAM. Returns 0 when unset.
func (b Budget) Ceiling(detectedTotal uint64) uint64 {
if !b.IsSet() {
return 0
}
var ceil uint64
if b.fraction > 0 {
ceil = uint64(float64(detectedTotal) * b.fraction)
} else {
ceil = b.absolute
}
if ceil > detectedTotal {
ceil = detectedTotal
}
return ceil
}
// Apply caps detected total/free against the budget. Returns the inputs
// unchanged when the budget is unset.
func (b Budget) Apply(detectedTotal, detectedFree uint64) (total, free uint64) {
if !b.IsSet() {
return detectedTotal, detectedFree
}
ceil := b.Ceiling(detectedTotal)
total = min(detectedTotal, ceil)
free = min(detectedFree, ceil)
return total, free
}
// String returns the canonical config form ("80%" or "12GB"), or "" when unset.
func (b Budget) String() string {
switch {
case b.fraction > 0:
return strconv.FormatFloat(b.fraction*100, 'f', -1, 64) + "%"
case b.absolute > 0:
return canonicalBytes(b.absolute)
default:
return ""
}
}
// canonicalBytes renders bytes using the largest decimal suffix that divides
// evenly, falling back to a raw byte count.
func canonicalBytes(v uint64) string {
for _, sfx := range []struct {
suffix string
mult uint64
}{
{"TB", 1000 * 1000 * 1000 * 1000},
{"GB", 1000 * 1000 * 1000},
{"MB", 1000 * 1000},
{"KB", 1000},
} {
if v%sfx.mult == 0 {
return strconv.FormatUint(v/sfx.mult, 10) + sfx.suffix
}
}
return strconv.FormatUint(v, 10) + "B"
}