mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
* 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>
159 lines
4.7 KiB
Go
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"
|
|
}
|