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>
This commit is contained in:
LocalAI [bot]
2026-07-15 09:58:45 +02:00
committed by GitHub
parent 3601174ce0
commit 8cec22c3b7
38 changed files with 1365 additions and 24 deletions

View File

@@ -19,6 +19,8 @@ import (
"github.com/mudler/LocalAI/pkg/modelartifacts"
"github.com/mudler/LocalAI/pkg/signals"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/mudler/xlog"
)
@@ -96,6 +98,7 @@ type RunCMD struct {
WatchdogInterval string `env:"LOCALAI_WATCHDOG_INTERVAL,WATCHDOG_INTERVAL" default:"500ms" help:"Interval between watchdog checks (e.g., 500ms, 5s, 1m) (default: 500ms)" group:"backends"`
EnableMemoryReclaimer bool `env:"LOCALAI_MEMORY_RECLAIMER,MEMORY_RECLAIMER,LOCALAI_GPU_RECLAIMER,GPU_RECLAIMER" default:"false" help:"Enable memory threshold monitoring to auto-evict backends when memory usage exceeds threshold (uses GPU VRAM if available, otherwise RAM)" group:"backends"`
MemoryReclaimerThreshold float64 `env:"LOCALAI_MEMORY_RECLAIMER_THRESHOLD,MEMORY_RECLAIMER_THRESHOLD,LOCALAI_GPU_RECLAIMER_THRESHOLD,GPU_RECLAIMER_THRESHOLD" default:"0.95" help:"Memory usage threshold (0.0-1.0) that triggers backend eviction (default 0.95 = 95%%)" group:"backends"`
VRAMBudget string `env:"LOCALAI_VRAM_BUDGET" help:"Cap VRAM used for model allocation on this node, as a percentage (e.g. 80%) or absolute amount (e.g. 12GB). Empty uses all detected VRAM." group:"backends"`
ForceEvictionWhenBusy bool `env:"LOCALAI_FORCE_EVICTION_WHEN_BUSY,FORCE_EVICTION_WHEN_BUSY" default:"false" help:"Force eviction even when models have active API calls (default: false for safety)" group:"backends"`
SizeAwareEviction bool `env:"LOCALAI_SIZE_AWARE_EVICTION,SIZE_AWARE_EVICTION" default:"false" help:"Evict the largest loaded model first rather than the least-recently-used one, keeping small utility models resident and maximizing freed memory per eviction" group:"backends"`
LRUEvictionMaxRetries int `env:"LOCALAI_LRU_EVICTION_MAX_RETRIES,LRU_EVICTION_MAX_RETRIES" default:"30" help:"Maximum number of retries when waiting for busy models to become idle before eviction (default: 30)" group:"backends"`
@@ -667,6 +670,20 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
opts = append(opts, config.WithPreferDevelopmentBackends(r.PreferDevelopmentBackends))
}
// Per-node VRAM allocation budget. Record it on the ApplicationConfig and,
// fail-open, install it as the process-global xsysinfo default so allocation
// heuristics cap at it. A malformed value must never block startup: it is
// logged and treated as unset (full detected VRAM).
if r.VRAMBudget != "" {
opts = append(opts, config.SetVRAMBudget(r.VRAMBudget))
if b, err := vrambudget.Parse(r.VRAMBudget); err != nil {
xlog.Warn("Ignoring invalid LOCALAI_VRAM_BUDGET", "value", r.VRAMBudget, "error", err)
} else {
xsysinfo.SetDefaultVRAMBudget(b)
xlog.Info("VRAM allocation budget set", "budget", b.String())
}
}
if r.PreloadBackendOnly {
_, err := application.New(opts...)
return err

View File

@@ -9,6 +9,7 @@ import (
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/modelartifacts"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/mudler/xlog"
)
@@ -133,6 +134,10 @@ type ApplicationConfig struct {
MemoryReclaimerEnabled bool // Enable memory threshold monitoring
MemoryReclaimerThreshold float64 // Threshold 0.0-1.0 (e.g., 0.95 = 95%)
// VRAMBudget optionally caps how much VRAM this instance uses for model
// allocation, as "80%" or "12GB". Empty = use full detected VRAM.
VRAMBudget string
// Eviction settings
ForceEvictionWhenBusy bool // Force eviction even when models have active API calls (default: false for safety)
SizeAwareEviction bool // Evict largest models first rather than least-recently-used (default: false)
@@ -464,6 +469,13 @@ func SetMemoryReclaimerThreshold(threshold float64) AppOption {
}
}
// SetVRAMBudget sets the VRAM allocation cap ("80%" or "12GB", "" = no cap).
func SetVRAMBudget(v string) AppOption {
return func(o *ApplicationConfig) {
o.VRAMBudget = v
}
}
// WithMemoryReclaimer configures the memory reclaimer with the given settings
func WithMemoryReclaimer(enabled bool, threshold float64) AppOption {
return func(o *ApplicationConfig) {
@@ -1124,6 +1136,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
lruEvictionMaxRetries := o.LRUEvictionMaxRetries
threads := o.Threads
contextSize := o.ContextSize
vramBudget := o.VRAMBudget
f16 := o.F16
debug := o.Debug
tracingMaxItems := o.TracingMaxItems
@@ -1218,6 +1231,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
LRUEvictionRetryInterval: &lruEvictionRetryInterval,
Threads: &threads,
ContextSize: &contextSize,
VRAMBudget: &vramBudget,
F16: &f16,
Debug: &debug,
TracingMaxItems: &tracingMaxItems,
@@ -1358,6 +1372,15 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
if settings.ContextSize != nil {
o.ContextSize = *settings.ContextSize
}
if settings.VRAMBudget != nil {
o.VRAMBudget = *settings.VRAMBudget
// Live-apply so the cap takes effect without a restart. An empty string
// clears the cap; a malformed value is rejected by the settings endpoint,
// but stay fail-open here too so a bad persisted value cannot wedge apply.
if b, err := vrambudget.Parse(o.VRAMBudget); err == nil {
xsysinfo.SetDefaultVRAMBudget(b)
}
}
if settings.F16 != nil {
o.F16 = *settings.F16
}

View File

@@ -648,4 +648,11 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
Expect(appConfig.SingleBackend).To(BeFalse()) // 3 != 1, so single backend is false
})
})
Describe("SetVRAMBudget", func() {
It("stores the VRAM budget via SetVRAMBudget", func() {
o := NewApplicationConfig(SetVRAMBudget("80%"))
Expect(o.VRAMBudget).To(Equal("80%"))
})
})
})

View File

@@ -28,19 +28,20 @@ type RuntimeSettings struct {
// Eviction settings
ForceEvictionWhenBusy *bool `json:"force_eviction_when_busy,omitempty"` // Force eviction even when models have active API calls (default: false for safety)
SizeAwareEviction *bool `json:"size_aware_eviction,omitempty"` // Evict largest models first rather than least-recently-used (default: false)
SizeAwareEviction *bool `json:"size_aware_eviction,omitempty"` // Evict largest models first rather than least-recently-used (default: false)
LRUEvictionMaxRetries *int `json:"lru_eviction_max_retries,omitempty"` // Maximum number of retries when waiting for busy models to become idle (default: 30)
LRUEvictionRetryInterval *string `json:"lru_eviction_retry_interval,omitempty"` // Interval between retries when waiting for busy models (e.g., 1s, 2s) (default: 1s)
// Performance settings
Threads *int `json:"threads,omitempty"`
ContextSize *int `json:"context_size,omitempty"`
F16 *bool `json:"f16,omitempty"`
Debug *bool `json:"debug,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
Threads *int `json:"threads,omitempty"`
ContextSize *int `json:"context_size,omitempty"`
VRAMBudget *string `json:"vram_budget,omitempty"` // Cap VRAM for allocation ("80%" or "12GB"; "" = no cap)
F16 *bool `json:"f16,omitempty"`
Debug *bool `json:"debug,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
TracingMaxBodyBytes *int `json:"tracing_max_body_bytes,omitempty"` // Per-body cap in bytes; 0 disables the cap
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
// Security/CORS settings
CORS *bool `json:"cors,omitempty"`

View File

@@ -9,6 +9,8 @@ import (
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/LocalAI/pkg/xsysinfo"
)
func strPtr(s string) *string { return &s }
@@ -93,6 +95,38 @@ var _ = Describe("RuntimeSettings persistence helpers", func() {
})
})
// VRAMBudget round trip pins the Settings-page persistence contract: the
// operator-set cap must survive ToRuntimeSettings (GET /api/settings) ->
// ApplyRuntimeSettings (POST /api/settings) so it lives past a save, and an
// empty value must clear the cap rather than being dropped.
Describe("VRAMBudget round trip", func() {
// ApplyRuntimeSettings live-applies the cap through the process-global
// xsysinfo.SetDefaultVRAMBudget. Reset it after each spec so a cap set
// here cannot bleed into other core/config specs under Ginkgo's
// randomized ordering.
AfterEach(func() {
xsysinfo.SetDefaultVRAMBudget(vrambudget.Budget{})
})
It("round-trips the VRAM budget", func() {
o := config.NewApplicationConfig(config.SetVRAMBudget("80%"))
rs := o.ToRuntimeSettings()
Expect(rs.VRAMBudget).NotTo(BeNil())
Expect(*rs.VRAMBudget).To(Equal("80%"))
o2 := config.NewApplicationConfig()
o2.ApplyRuntimeSettings(&rs)
Expect(o2.VRAMBudget).To(Equal("80%"))
})
It("applies an empty VRAM budget as clearing the cap", func() {
o := config.NewApplicationConfig(config.SetVRAMBudget("80%"))
empty := ""
o.ApplyRuntimeSettings(&config.RuntimeSettings{VRAMBudget: &empty})
Expect(o.VRAMBudget).To(Equal(""))
})
})
// MITM round trip pins the contract that loadRuntimeSettingsFromFile
// MITM listener address must survive a write/read round trip so the
// next process restart can bring the listener back up. (Intercept

View File

@@ -30,6 +30,7 @@ import (
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
"github.com/mudler/LocalAI/pkg/httpclient"
"github.com/mudler/LocalAI/pkg/natsauth"
"github.com/mudler/LocalAI/pkg/vrambudget"
)
// nodeError builds a schema.ErrorResponse for node endpoints.
@@ -92,6 +93,9 @@ type RegisterNodeRequest struct {
// Workers older than this field omit it; we coerce 0 → 1 below to preserve
// historical single-replica behavior.
MaxReplicasPerModel int `json:"max_replicas_per_model,omitempty"`
// VRAMBudget is the worker's operator-set VRAM cap ("80%" or "12GB"). The
// registry resolves and enforces it against the raw reported VRAM.
VRAMBudget string `json:"vram_budget,omitempty"`
}
// RegisterNodeEndpoint registers a new backend node.
@@ -171,6 +175,7 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
GPUVendor: req.GPUVendor,
GPUComputeCapability: req.GPUComputeCapability,
MaxReplicasPerModel: maxReplicasPerModel,
VRAMBudget: req.VRAMBudget,
}
ctx := c.Request().Context()
@@ -942,6 +947,74 @@ func ResetMaxReplicasPerModelEndpoint(registry *nodes.NodeRegistry) echo.Handler
}
}
// UpdateVRAMBudgetRequest is the body for the per-node VRAM budget endpoint.
type UpdateVRAMBudgetRequest struct {
// Value is the VRAM cap ("80%" or "12GB"). Empty string clears the cap.
Value string `json:"value"`
}
// UpdateVRAMBudgetEndpoint sets a node's VRAM allocation cap as a sticky admin
// override. The value is validated, resolved against the node's total VRAM, and
// applied to available_vram immediately so scheduling reflects it before the
// next heartbeat.
//
// @Summary Update a node's VRAM allocation budget
// @Tags Nodes
// @Param id path string true "Node ID"
// @Param request body UpdateVRAMBudgetRequest true "New value (\"80%\" or \"12GB\"; empty clears)"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]any "invalid budget"
// @Failure 404 {object} map[string]any "node not found"
// @Router /api/nodes/{id}/vram-budget [put]
func UpdateVRAMBudgetEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
nodeID := c.Param("id")
if _, err := registry.Get(ctx, nodeID); err != nil {
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
}
var req UpdateVRAMBudgetRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "invalid request body"))
}
// An empty value clears the cap; only a non-empty value must parse.
if req.Value != "" {
if _, err := vrambudget.Parse(req.Value); err != nil {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, fmt.Sprintf("invalid vram budget: %v", err)))
}
}
if err := registry.UpdateVRAMBudget(ctx, nodeID, req.Value); err != nil {
xlog.Error("Failed to update vram_budget", "node", nodeID, "value", req.Value, "error", err)
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to update vram budget"))
}
return c.JSON(http.StatusOK, map[string]string{"vram_budget": req.Value})
}
}
// ResetVRAMBudgetEndpoint clears the admin override so the worker's
// LOCALAI_VRAM_BUDGET takes over again on next registration.
//
// @Summary Reset a node's VRAM budget to the worker default
// @Tags Nodes
// @Param id path string true "Node ID"
// @Success 200 {object} map[string]bool
// @Failure 404 {object} map[string]any "node not found"
// @Router /api/nodes/{id}/vram-budget [delete]
func ResetVRAMBudgetEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := c.Request().Context()
nodeID := c.Param("id")
if _, err := registry.Get(ctx, nodeID); err != nil {
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
}
if err := registry.ResetVRAMBudget(ctx, nodeID); err != nil {
xlog.Error("Failed to reset vram_budget override", "node", nodeID, "error", err)
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to reset vram budget"))
}
return c.JSON(http.StatusOK, map[string]bool{"reset": true})
}
}
// SetNodeLabelsEndpoint replaces all labels for a node.
func SetNodeLabelsEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
return func(c echo.Context) error {

View File

@@ -0,0 +1,99 @@
package localai
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/testutil"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Node VRAM budget HTTP handlers", func() {
var registry *nodes.NodeRegistry
BeforeEach(func() {
db := testutil.SetupTestDB()
var err error
registry, err = nodes.NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
})
// seedHealthyNode registers a healthy node with the given total/available
// VRAM and returns its ID.
seedHealthyNode := func(ctx context.Context, total, available uint64) string {
node := &nodes.BackendNode{
ID: "vram-node",
Name: "vram-node",
Address: "10.0.0.9:50051",
Status: nodes.StatusHealthy,
TotalVRAM: total,
AvailableVRAM: available,
}
Expect(registry.Register(ctx, node, true)).To(Succeed())
return node.ID
}
// doPUT drives UpdateVRAMBudgetEndpoint for the given node ID and body.
doPUT := func(id, body string) *httptest.ResponseRecorder {
e := echo.New()
req := httptest.NewRequest(http.MethodPut, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
Expect(UpdateVRAMBudgetEndpoint(registry)(c)).To(Succeed())
return rec
}
// doDELETE drives ResetVRAMBudgetEndpoint for the given node ID.
doDELETE := func(id string) *httptest.ResponseRecorder {
e := echo.New()
req := httptest.NewRequest(http.MethodDelete, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
Expect(ResetVRAMBudgetEndpoint(registry)(c)).To(Succeed())
return rec
}
It("sets and clears a node's VRAM budget", func(ctx SpecContext) {
id := seedHealthyNode(ctx, 16_000_000_000, 16_000_000_000)
rec := doPUT(id, `{"value":"50%"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
node, err := registry.Get(ctx, id)
Expect(err).ToNot(HaveOccurred())
Expect(node.VRAMBudget).To(Equal("50%"))
Expect(node.AvailableVRAM).To(Equal(uint64(8_000_000_000)))
rec = doDELETE(id)
Expect(rec.Code).To(Equal(http.StatusOK))
node, err = registry.Get(ctx, id)
Expect(err).ToNot(HaveOccurred())
Expect(node.VRAMBudget).To(Equal(""))
})
It("rejects a malformed budget with 400", func(ctx SpecContext) {
id := seedHealthyNode(ctx, 16_000_000_000, 16_000_000_000)
rec := doPUT(id, `{"value":"120%"}`)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns 404 for an unknown node on update", func(ctx SpecContext) {
rec := doPUT("does-not-exist", `{"value":"50%"}`)
Expect(rec.Code).To(Equal(http.StatusNotFound))
})
It("returns 404 for an unknown node on reset", func(ctx SpecContext) {
rec := doDELETE("does-not-exist")
Expect(rec.Code).To(Equal(http.StatusNotFound))
})
})

View File

@@ -12,6 +12,7 @@ import (
"github.com/mudler/LocalAI/core/http/endpoints/openresponses"
"github.com/mudler/LocalAI/core/p2p"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/xlog"
)
@@ -94,6 +95,18 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
}
}
// Reject a malformed VRAM budget up front so a bad value never reaches
// the config (ApplyRuntimeSettings is fail-open and would silently skip
// installing the cap). An empty string is allowed: it clears the cap.
if settings.VRAMBudget != nil && *settings.VRAMBudget != "" {
if _, err := vrambudget.Parse(*settings.VRAMBudget); err != nil {
return c.JSON(http.StatusBadRequest, schema.SettingsResponse{
Success: false,
Error: "Invalid vram_budget format: " + err.Error(),
})
}
}
// Generate P2P token before saving so the real token is persisted (not "0")
if settings.P2PToken != nil && *settings.P2PToken == "0" {
token := p2p.GenerateToken(60, 60)

View File

@@ -84,6 +84,10 @@ func (stubClient) ListNodes(_ context.Context) ([]localaitools.Node, error) {
return []localaitools.Node{}, nil
}
func (stubClient) SetNodeVRAMBudget(_ context.Context, _, _ string) error {
return nil
}
func (stubClient) VRAMEstimate(_ context.Context, _ localaitools.VRAMEstimateRequest) (*vram.EstimateResult, error) {
return &vram.EstimateResult{SizeDisplay: "stub"}, nil
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { nodesApi } from '../../utils/api'
import LoadingSpinner from '../LoadingSpinner'
import { formatVRAM } from './nodeStatus'
/**
* Inline editor for a node's per-model replica capacity.
@@ -81,7 +82,69 @@ export default function CapacityEditor({ node, loadedModelCounts, onUpdate, conf
}
}, [node, onUpdate, addToast])
// VRAM allocation budget: a free-form string ("80%" or "12GB") resolved to a
// byte ceiling server-side. Presence of vram_budget signals an active budget.
const currentBudget = node.vram_budget || ''
const hasBudget = !!currentBudget
const budgetBytes = node.vram_budget_bytes || 0
const [budgetEditing, setBudgetEditing] = useState(false)
const [budgetDraft, setBudgetDraft] = useState(currentBudget)
const [budgetSaving, setBudgetSaving] = useState(false)
const [budgetClearing, setBudgetClearing] = useState(false)
useEffect(() => {
if (!budgetEditing) setBudgetDraft(currentBudget)
}, [currentBudget, budgetEditing])
const cancelBudget = useCallback(() => {
setBudgetEditing(false)
setBudgetDraft(currentBudget)
}, [currentBudget])
const saveBudget = useCallback(async () => {
const value = budgetDraft.trim()
if (value === currentBudget) {
setBudgetEditing(false)
return
}
setBudgetSaving(true)
try {
await nodesApi.updateVramBudget(node.id, value)
addToast(
value
? `VRAM budget set to ${value} on ${node.name}`
: `VRAM budget cleared on ${node.name}`,
'success',
)
setBudgetEditing(false)
onUpdate?.(value)
} catch (err) {
addToast(`Could not change VRAM budget: ${err.message || err}`, 'error')
} finally {
setBudgetSaving(false)
}
}, [budgetDraft, currentBudget, node, onUpdate, addToast])
const onBudgetKeyDown = (e) => {
if (e.key === 'Enter') { e.preventDefault(); saveBudget() }
else if (e.key === 'Escape') { e.preventDefault(); cancelBudget() }
}
const clearBudget = useCallback(async () => {
setBudgetClearing(true)
try {
await nodesApi.resetVramBudget(node.id)
addToast(`VRAM budget cleared on ${node.name}`, 'success')
onUpdate?.(null)
} catch (err) {
addToast(`Could not clear VRAM budget: ${err.message || err}`, 'error')
} finally {
setBudgetClearing(false)
}
}, [node, onUpdate, addToast])
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)' }}>
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-md)',
}}>
@@ -192,5 +255,110 @@ export default function CapacityEditor({ node, loadedModelCounts, onUpdate, conf
</div>
</div>
</div>
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--spacing-md)',
}}>
<i className="fas fa-microchip" style={{ color: 'var(--color-text-muted)', marginTop: 3 }} aria-hidden="true" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', flexWrap: 'wrap' }}>
<label
htmlFor={`vram-budget-${node.id}`}
style={{ fontSize: '0.8125rem', fontWeight: 600, color: 'var(--color-text-primary)' }}
>
VRAM budget
</label>
{budgetEditing ? (
<>
<input
id={`vram-budget-${node.id}`}
type="text"
value={budgetDraft}
disabled={budgetSaving}
placeholder="e.g. 80% or 12GB"
onChange={(e) => setBudgetDraft(e.target.value)}
onKeyDown={onBudgetKeyDown}
autoFocus
aria-describedby={`vram-budget-hint-${node.id}`}
style={{
width: 140, padding: '4px 8px', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)', background: 'var(--color-bg-primary)',
fontFamily: 'var(--font-mono)', fontSize: '0.8125rem',
color: 'var(--color-text-primary)',
}}
/>
<button
className="btn btn-primary btn-sm"
onClick={saveBudget}
disabled={budgetSaving}
style={{ minHeight: 32 }}
aria-label="Save VRAM budget"
>
{budgetSaving ? <LoadingSpinner size="xs" /> : <><i className="fas fa-check" /> Save</>}
</button>
<button
className="btn btn-secondary btn-sm"
onClick={cancelBudget}
disabled={budgetSaving}
style={{ minHeight: 32 }}
aria-label="Cancel"
>
Cancel
</button>
</>
) : (
<>
<span
className="cell-mono"
style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}
>
{hasBudget ? currentBudget : 'none'}
</span>
{hasBudget && budgetBytes > 0 && (
<span
className="cell-mono"
title="Resolved allocation ceiling out of this node's total VRAM"
style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}
>
{formatVRAM(budgetBytes)} / {formatVRAM(node.total_vram)}
</span>
)}
<button
onClick={() => setBudgetEditing(true)}
aria-label={`Edit VRAM budget (currently ${hasBudget ? currentBudget : 'none'})`}
title="Change the VRAM allocation budget for this node"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 32, minHeight: 32, padding: 4, borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-subtle)',
background: 'transparent', color: 'var(--color-text-muted)', cursor: 'pointer',
}}
>
<i className="fas fa-pencil-alt" />
</button>
{hasBudget && (
<button
onClick={clearBudget}
disabled={budgetClearing}
aria-label="Clear the VRAM budget for this node"
title="Clear the VRAM budget; this node's full VRAM becomes available again"
className="btn btn-secondary btn-sm"
style={{ minHeight: 32 }}
>
{budgetClearing ? <LoadingSpinner size="xs" /> : <><i className="fas fa-undo" /> Clear</>}
</button>
)}
</>
)}
</div>
<div
id={`vram-budget-hint-${node.id}`}
style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginTop: 4, lineHeight: 1.4 }}
>
Cap how much of this node's VRAM the scheduler may allocate. Accepts a percentage or a size (e.g. 80% or 12GB).
</div>
</div>
</div>
</div>
)
}

View File

@@ -419,6 +419,9 @@ export default function Settings() {
<SettingRow label="Default Context Size" description="Default context window size for models">
<input className="input" type="number" style={{ width: 120 }} value={settings.context_size ?? ''} onChange={(e) => update('context_size', parseInt(e.target.value) || 0)} placeholder="2048" />
</SettingRow>
<SettingRow label="VRAM Budget" description="Cap VRAM used for model allocation on this node. Percentage (e.g. 80%) or absolute (e.g. 12GB). Empty uses all detected VRAM.">
<input className="input" type="text" style={{ width: 120 }} value={settings.vram_budget ?? ''} onChange={(e) => update('vram_budget', e.target.value)} placeholder="e.g. 80% or 12GB" />
</SettingRow>
<SettingRow label="F16 Precision" description="Use 16-bit floating point for reduced memory usage">
<Toggle checked={settings.f16} onChange={(v) => update('f16', v)} />
</SettingRow>

View File

@@ -590,6 +590,17 @@ export const nodesApi = {
resetMaxReplicasPerModel: (id) => fetchJSON(API_CONFIG.endpoints.nodeMaxReplicasPerModel(id), {
method: 'DELETE',
}),
// Set a sticky admin override for the per-node VRAM allocation budget. The
// value is a string ("80%" or "12GB"); resolution to a byte ceiling happens
// server-side. Call resetVramBudget to clear the override entirely.
updateVramBudget: (id, value) => fetchJSON(API_CONFIG.endpoints.nodeVramBudget(id), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
}),
resetVramBudget: (id) => fetchJSON(API_CONFIG.endpoints.nodeVramBudget(id), {
method: 'DELETE',
}),
listScheduling: () => fetchJSON(API_CONFIG.endpoints.nodesScheduling),
allModels: () => fetchJSON(API_CONFIG.endpoints.nodesModels),
setScheduling: (config) => postJSON(API_CONFIG.endpoints.nodesScheduling, config),

View File

@@ -145,6 +145,7 @@ export const API_CONFIG = {
nodeLabels: (id) => `/api/nodes/${id}/labels`,
nodeLabelKey: (id, key) => `/api/nodes/${id}/labels/${key}`,
nodeMaxReplicasPerModel: (id) => `/api/nodes/${id}/max-replicas-per-model`,
nodeVramBudget: (id) => `/api/nodes/${id}/vram-budget`,
nodesScheduling: '/api/nodes/scheduling',
nodesModels: '/api/nodes/models',
nodesSchedulingModel: (model) => `/api/nodes/scheduling/${encodeURIComponent(model)}`,

View File

@@ -116,6 +116,12 @@ func RegisterNodeAdminRoutes(e *echo.Echo, registry *nodes.NodeRegistry, unloade
admin.PUT("/:id/max-replicas-per-model", localai.UpdateMaxReplicasPerModelEndpoint(registry))
admin.DELETE("/:id/max-replicas-per-model", localai.ResetMaxReplicasPerModelEndpoint(registry))
// Per-node VRAM allocation budget. PUT sets a sticky admin override that
// survives worker restarts; DELETE clears it so the worker's reported
// budget takes over again at the next re-registration.
admin.PUT("/:id/vram-budget", localai.UpdateVRAMBudgetEndpoint(registry))
admin.DELETE("/:id/vram-budget", localai.ResetVRAMBudgetEndpoint(registry))
// WebSocket proxy for real-time log streaming from workers
e.GET("/ws/nodes/:id/backend-logs/:modelId", localai.NodeBackendLogsWSEndpoint(registry, registrationToken), readyMw, adminMw)
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/services/advisorylock"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/xlog"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -50,12 +51,24 @@ type BackendNode struct {
// admin override. When true, the worker's CLI value is ignored on
// re-registration so the override survives worker restarts. Cleared
// by an explicit "reset to worker default" action.
MaxReplicasPerModelManuallySet bool `gorm:"column:max_replicas_per_model_manually_set;default:false" json:"max_replicas_per_model_manually_set"`
APIKeyID string `gorm:"size:36" json:"-"` // auto-provisioned API key ID (for cleanup)
AuthUserID string `gorm:"size:36" json:"-"` // auto-provisioned user ID (for cleanup)
LastHeartbeat time.Time `gorm:"column:last_heartbeat" json:"last_heartbeat"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MaxReplicasPerModelManuallySet bool `gorm:"column:max_replicas_per_model_manually_set;default:false" json:"max_replicas_per_model_manually_set"`
// VRAMBudget is the operator-set allocation cap for this node ("80%" or
// "12GB"; empty = no cap). VRAMBudgetBytes is that budget resolved to an
// absolute ceiling against the node's raw TotalVRAM (0 = none) and is the
// value actually enforced: available_vram is written as min(reported,
// ceiling) so the SQL scheduler places against budgeted capacity. TotalVRAM
// stays raw so a percentage budget can be recomputed if capacity changes.
VRAMBudget string `gorm:"column:vram_budget;size:32" json:"vram_budget,omitempty"`
VRAMBudgetBytes uint64 `gorm:"column:vram_budget_bytes;default:0" json:"vram_budget_bytes,omitempty"`
// VRAMBudgetManuallySet marks the budget as a UI-set admin override so the
// worker's re-registration value does not clobber it (mirrors
// MaxReplicasPerModelManuallySet).
VRAMBudgetManuallySet bool `gorm:"column:vram_budget_manually_set;default:false" json:"vram_budget_manually_set"`
APIKeyID string `gorm:"size:36" json:"-"` // auto-provisioned API key ID (for cleanup)
AuthUserID string `gorm:"size:36" json:"-"` // auto-provisioned user ID (for cleanup)
LastHeartbeat time.Time `gorm:"column:last_heartbeat" json:"last_heartbeat"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
@@ -77,6 +90,8 @@ const (
ColGPUComputeCap = "gpu_compute_capability"
ColLastHeartbeat = "last_heartbeat"
ColMaxReplicasPerModel = "max_replicas_per_model"
ColVRAMBudget = "vram_budget"
ColVRAMBudgetBytes = "vram_budget_bytes"
)
// NodeModel tracks which models are loaded on which nodes.
@@ -305,6 +320,36 @@ func NewNodeRegistry(db *gorm.DB) (*NodeRegistry, error) {
return &NodeRegistry{db: db}, nil
}
// resolveVRAMBudgetBytes turns a budget string into an absolute byte ceiling
// against the node's raw total VRAM, clamped to that total. 0 means no cap or
// unparseable (fail-open at this layer; the endpoint validates input).
func (r *NodeRegistry) resolveVRAMBudgetBytes(budget string, totalVRAM uint64) uint64 {
if budget == "" {
return 0
}
b, err := vrambudget.Parse(budget)
if err != nil {
xlog.Warn("Ignoring invalid node VRAM budget", "budget", budget, "error", err)
return 0
}
return b.Ceiling(totalVRAM)
}
// ResolveVRAMBudgetBytesForTest exposes resolveVRAMBudgetBytes for tests.
func (r *NodeRegistry) ResolveVRAMBudgetBytesForTest(budget string, totalVRAM uint64) uint64 {
return r.resolveVRAMBudgetBytes(budget, totalVRAM)
}
// capAvailable applies a node's resolved budget ceiling to a reported available
// figure. ceilingBytes == 0 means no cap; otherwise the result is the smaller
// of the two so the SQL scheduler never sees more free VRAM than the budget.
func capAvailable(reported, ceilingBytes uint64) uint64 {
if ceilingBytes == 0 || reported <= ceilingBytes {
return reported
}
return ceilingBytes
}
// Register adds or updates a backend node.
// If autoApprove is true, the node goes directly to "healthy" status.
// If false, new nodes start in "pending" status and must be approved by an admin.
@@ -338,9 +383,37 @@ func (r *NodeRegistry) Register(ctx context.Context, node *BackendNode, autoAppr
node.MaxReplicasPerModel = existing.MaxReplicasPerModel
node.MaxReplicasPerModelManuallySet = true
}
// Preserve an admin-set VRAM budget across worker re-registration;
// otherwise the worker-reported budget (wired in a later task) wins.
if existing.VRAMBudgetManuallySet {
node.VRAMBudget = existing.VRAMBudget
node.VRAMBudgetManuallySet = true
}
// Resolve the effective budget against the (possibly updated) raw total
// VRAM and cap the reported available so the SQL scheduler places against
// budgeted capacity. TotalVRAM is written raw.
node.VRAMBudgetBytes = r.resolveVRAMBudgetBytes(node.VRAMBudget, node.TotalVRAM)
node.AvailableVRAM = capAvailable(node.AvailableVRAM, node.VRAMBudgetBytes)
if err := updateDB.Updates(node).Error; err != nil {
return fmt.Errorf("updating node %s: %w", node.Name, err)
}
// The struct Updates above zero-skips fields, so a worker that dropped
// LOCALAI_VRAM_BUDGET (now reporting an empty budget / 0 ceiling) would
// leave the previously-stored cap in place, so the operator's env removal
// would never take effect. For a node whose budget is NOT an admin
// override, force-write the worker-authoritative budget columns even
// when empty/zero so removing the budget actually clears the cap.
// Manual overrides are handled above and keep the admin value untouched.
if !existing.VRAMBudgetManuallySet {
if err := r.db.WithContext(ctx).Model(&BackendNode{}).Where("id = ?", node.ID).
Updates(map[string]any{
ColVRAMBudget: node.VRAMBudget,
ColVRAMBudgetBytes: node.VRAMBudgetBytes,
ColAvailableVRAM: node.AvailableVRAM,
}).Error; err != nil {
return fmt.Errorf("clearing worker VRAM budget for node %s: %w", node.Name, err)
}
}
// Preserve auth references from existing record.
// GORM Updates(struct) skips zero-value fields, so the DB retains
// the old auth_user_id/api_key_id but the caller's struct is empty.
@@ -380,6 +453,10 @@ func (r *NodeRegistry) Register(ctx context.Context, node *BackendNode, autoAppr
} else {
node.Status = StatusPending
}
// Resolve a worker-reported budget (wired in a later task) against the
// node's raw total VRAM and cap the reported available accordingly.
node.VRAMBudgetBytes = r.resolveVRAMBudgetBytes(node.VRAMBudget, node.TotalVRAM)
node.AvailableVRAM = capAvailable(node.AvailableVRAM, node.VRAMBudgetBytes)
if err := r.db.WithContext(ctx).Create(node).Error; err != nil {
return fmt.Errorf("creating node %s: %w", node.Name, err)
}
@@ -617,7 +694,13 @@ func (r *NodeRegistry) Heartbeat(ctx context.Context, nodeID string, update *Hea
if update != nil {
if update.AvailableVRAM != nil {
updates[ColAvailableVRAM] = *update.AvailableVRAM
// Cap the reported available against the node's resolved budget
// ceiling (0 = none) so the SQL scheduler only ever sees budgeted
// capacity. TotalVRAM stays raw (written below).
var ceiling uint64
db.Model(&BackendNode{}).
Select(ColVRAMBudgetBytes).Where("id = ?", nodeID).Scan(&ceiling)
updates[ColAvailableVRAM] = capAvailable(*update.AvailableVRAM, ceiling)
// The worker is the source of truth for actual free VRAM.
// Whenever it sends us a fresh reading, the in-tick soft
// reservation is no longer needed — clear it. (See ReserveVRAM.)
@@ -1670,6 +1753,57 @@ func (r *NodeRegistry) ResetMaxReplicasPerModel(ctx context.Context, nodeID stri
return nil
}
// UpdateVRAMBudget sets a node's VRAM allocation cap as a sticky admin override.
// It resolves the budget against the node's raw TotalVRAM, stores the ceiling,
// and immediately re-caps available_vram so the scheduler reflects the change
// before the next heartbeat. Empty budget clears the cap. The override survives
// worker re-registration (see Register); to hand control back to the worker,
// call ResetVRAMBudget.
func (r *NodeRegistry) UpdateVRAMBudget(ctx context.Context, nodeID, budget string) error {
node, err := r.Get(ctx, nodeID)
if err != nil {
return err
}
ceiling := r.resolveVRAMBudgetBytes(budget, node.TotalVRAM)
res := r.db.WithContext(ctx).Model(&BackendNode{}).Where("id = ?", nodeID).
Updates(map[string]any{
ColVRAMBudget: budget,
ColVRAMBudgetBytes: ceiling,
"vram_budget_manually_set": true,
ColAvailableVRAM: capAvailable(node.AvailableVRAM, ceiling),
})
if res.Error != nil {
return fmt.Errorf("updating vram_budget on %s: %w", nodeID, res.Error)
}
if res.RowsAffected == 0 {
return fmt.Errorf("node %s not found", nodeID)
}
// Capping available_vram may have freed or constrained capacity; wake any
// configs the reconciler put in cooldown so the next tick re-evaluates.
if err := r.ClearAllUnsatisfiable(ctx); err != nil {
xlog.Warn("Failed to clear unsatisfiable flags after vram budget change", "error", err)
}
return nil
}
// ResetVRAMBudget clears the admin override and the resolved ceiling, handing
// budget control back to the worker's reported budget on next register.
func (r *NodeRegistry) ResetVRAMBudget(ctx context.Context, nodeID string) error {
res := r.db.WithContext(ctx).Model(&BackendNode{}).Where("id = ?", nodeID).
Updates(map[string]any{
ColVRAMBudget: "",
ColVRAMBudgetBytes: 0,
"vram_budget_manually_set": false,
})
if res.Error != nil {
return fmt.Errorf("clearing vram_budget on %s: %w", nodeID, res.Error)
}
if res.RowsAffected == 0 {
return fmt.Errorf("node %s not found", nodeID)
}
return nil
}
// ClearAllUnsatisfiable clears the cooldown flag on every scheduling config.
// Called from cluster-events that could plausibly increase capacity (new
// node registers, node approves pending→healthy, node labels change,

View File

@@ -0,0 +1,169 @@
package nodes
import (
"context"
"runtime"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/testutil"
"gorm.io/gorm"
)
var _ = Describe("Node VRAM budget", func() {
var (
db *gorm.DB
registry *NodeRegistry
)
// gb is 1000-based to match vrambudget's decimal "GB" suffix.
const gb = uint64(1000 * 1000 * 1000)
BeforeEach(func() {
if runtime.GOOS == "darwin" {
Skip("testcontainers requires Docker, not available on macOS CI")
}
db = testutil.SetupTestDB()
var err error
registry, err = NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
})
// seedHealthyNode registers an auto-approved backend node with raw total and
// available VRAM and returns its ID.
seedHealthyNode := func(ctx context.Context, name string, total, avail uint64) string {
node := &BackendNode{
Name: name,
NodeType: NodeTypeBackend,
Address: "10.0.0.1:50051",
TotalVRAM: total,
AvailableVRAM: avail,
}
Expect(registry.Register(ctx, node, true)).To(Succeed())
return node.ID
}
It("resolves a percentage budget against the node's raw total VRAM", func() {
Expect(registry.ResolveVRAMBudgetBytesForTest("80%", 10*gb)).To(Equal(8 * gb))
Expect(registry.ResolveVRAMBudgetBytesForTest("12GB", 10*gb)).To(Equal(10 * gb)) // clamped to physical
Expect(registry.ResolveVRAMBudgetBytesForTest("", 10*gb)).To(Equal(uint64(0)))
})
It("caps stored available_vram when an admin sets a budget", func(ctx SpecContext) {
id := seedHealthyNode(ctx, "worker-budget-1", 16*gb, 16*gb)
Expect(registry.UpdateVRAMBudget(ctx, id, "50%")).To(Succeed())
node, err := registry.Get(ctx, id)
Expect(err).NotTo(HaveOccurred())
Expect(node.TotalVRAM).To(Equal(16 * gb)) // raw preserved
Expect(node.VRAMBudgetBytes).To(Equal(8 * gb))
Expect(node.AvailableVRAM).To(Equal(8 * gb)) // capped
Expect(node.VRAMBudgetManuallySet).To(BeTrue())
})
It("re-caps available_vram on heartbeat against the stored ceiling", func(ctx SpecContext) {
id := seedHealthyNode(ctx, "worker-budget-2", 16*gb, 16*gb)
Expect(registry.UpdateVRAMBudget(ctx, id, "8GB")).To(Succeed())
avail := 15 * gb
Expect(registry.Heartbeat(ctx, id, &HeartbeatUpdate{AvailableVRAM: &avail})).To(Succeed())
node, err := registry.Get(ctx, id)
Expect(err).NotTo(HaveOccurred())
Expect(node.AvailableVRAM).To(Equal(8 * gb)) // reported 15GB capped to 8GB budget
})
It("preserves an admin override across worker re-registration", func(ctx SpecContext) {
id := seedHealthyNode(ctx, "worker-budget-3", 16*gb, 16*gb)
Expect(registry.UpdateVRAMBudget(ctx, id, "50%")).To(Succeed())
// Worker re-registers reporting full available VRAM and no budget.
reReg := &BackendNode{
Name: "worker-budget-3",
NodeType: NodeTypeBackend,
Address: "10.0.0.1:50051",
TotalVRAM: 16 * gb,
AvailableVRAM: 16 * gb,
}
Expect(registry.Register(ctx, reReg, true)).To(Succeed())
node, err := registry.Get(ctx, id)
Expect(err).NotTo(HaveOccurred())
Expect(node.VRAMBudgetManuallySet).To(BeTrue())
Expect(node.VRAMBudget).To(Equal("50%"))
Expect(node.VRAMBudgetBytes).To(Equal(8 * gb))
Expect(node.AvailableVRAM).To(Equal(8 * gb)) // re-capped despite worker reporting 16GB
})
It("clears the cap when the budget is reset", func(ctx SpecContext) {
id := seedHealthyNode(ctx, "worker-budget-4", 16*gb, 16*gb)
Expect(registry.UpdateVRAMBudget(ctx, id, "50%")).To(Succeed())
Expect(registry.ResetVRAMBudget(ctx, id)).To(Succeed())
node, err := registry.Get(ctx, id)
Expect(err).NotTo(HaveOccurred())
Expect(node.VRAMBudgetBytes).To(Equal(uint64(0)))
Expect(node.VRAMBudgetManuallySet).To(BeFalse())
})
// reregister re-registers an existing node by name, mirroring what the worker
// does on restart (raw total/available, and whatever budget it currently
// reports via LOCALAI_VRAM_BUDGET).
reregister := func(ctx context.Context, name, budget string, total, avail uint64) {
reReg := &BackendNode{
Name: name,
NodeType: NodeTypeBackend,
Address: "10.0.0.1:50051",
TotalVRAM: total,
AvailableVRAM: avail,
VRAMBudget: budget,
}
Expect(registry.Register(ctx, reReg, true)).To(Succeed())
}
It("clears a worker-reported budget when the worker re-registers without one", func(ctx SpecContext) {
// Worker first reports LOCALAI_VRAM_BUDGET=80%.
node := &BackendNode{
Name: "worker-clear-1",
NodeType: NodeTypeBackend,
Address: "10.0.0.1:50051",
TotalVRAM: 16 * gb,
AvailableVRAM: 16 * gb,
VRAMBudget: "80%",
}
Expect(registry.Register(ctx, node, true)).To(Succeed())
got, err := registry.Get(ctx, node.ID)
Expect(err).NotTo(HaveOccurred())
Expect(got.VRAMBudget).To(Equal("80%"))
Expect(got.VRAMBudgetBytes).To(BeNumerically(">", uint64(0)))
Expect(got.VRAMBudgetBytes).To(Equal(uint64(12.8 * float64(gb)))) // 80% of 16GB
Expect(got.AvailableVRAM).To(Equal(got.VRAMBudgetBytes)) // capped
// Operator removes LOCALAI_VRAM_BUDGET and restarts the worker: it now
// re-registers with an empty budget and full raw available. The stale
// 80% cap MUST be cleared, not preserved (struct Updates zero-skip bug).
reregister(ctx, "worker-clear-1", "", 16*gb, 16*gb)
got, err = registry.Get(ctx, node.ID)
Expect(err).NotTo(HaveOccurred())
Expect(got.VRAMBudget).To(Equal(""))
Expect(got.VRAMBudgetBytes).To(Equal(uint64(0)))
Expect(got.AvailableVRAM).To(Equal(16 * gb)) // back to raw reported
})
It("keeps an admin override when a worker re-registers without a budget", func(ctx SpecContext) {
// Regression guard for the force-clear: it must not clobber a sticky
// admin-set budget just because the worker stopped reporting one.
id := seedHealthyNode(ctx, "worker-clear-2", 16*gb, 16*gb)
Expect(registry.UpdateVRAMBudget(ctx, id, "50%")).To(Succeed())
reregister(ctx, "worker-clear-2", "", 16*gb, 16*gb)
node, err := registry.Get(ctx, id)
Expect(err).NotTo(HaveOccurred())
Expect(node.VRAMBudgetManuallySet).To(BeTrue())
Expect(node.VRAMBudget).To(Equal("50%"))
Expect(node.VRAMBudgetBytes).To(Equal(8 * gb))
Expect(node.AvailableVRAM).To(Equal(8 * gb)) // still capped by admin budget
})
})

View File

@@ -181,10 +181,20 @@ func applyNodeHardwareDefaults(opts *pb.ModelOptions, node *BackendNode, backend
if opts == nil || node == nil || config.HardwareDefaultsDisabled() {
return
}
// Gate the throughput heuristics on the node's BUDGETED ceiling, not its
// physical card. node.TotalVRAM is stored raw (so a percentage budget can be
// recomputed if capacity changes), but the batch/parallel boosts allocate a
// per-device compute buffer against real capacity: an operator who budgeted
// only a slice of the GPU to LocalAI must not get defaults sized for the full
// device, or the raised batch/slots would overflow the allocation (#10485).
usableVRAM := node.TotalVRAM
if node.VRAMBudgetBytes > 0 && node.VRAMBudgetBytes < usableVRAM {
usableVRAM = node.VRAMBudgetBytes
}
gpu := config.GPU{
Vendor: node.GPUVendor,
ComputeCapability: node.GPUComputeCapability,
VRAM: node.TotalVRAM,
VRAM: usableVRAM,
}
if config.IsManagedPhysicalBatch(int(opts.NBatch)) {
// Gate the raised batch on the selected node's per-device VRAM at this

View File

@@ -0,0 +1,40 @@
package nodes
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
var _ = Describe("applyNodeHardwareDefaults with VRAM budget", func() {
const gb = uint64(1000 * 1000 * 1000)
It("uses the capped ceiling, not raw TotalVRAM, for batch/parallel gating", func() {
// A Blackwell node whose RAW VRAM has ample headroom to keep the raised
// physical batch, but whose operator budget is far too small for it. The
// batch/parallel heuristics must gate on the budgeted ceiling, not the
// physical card, or a budgeted-tiny node would still get OOM-prone
// throughput defaults meant for the full device.
//
// The context is deliberately large: the compute-buffer headroom guard
// (PhysicalBatchForContext) scales the extra scratch by n_ctx, so at a
// small context even 2GB clears the guard. At 32768 the raised batch's
// scratch fits comfortably in 64GB (raw keeps 2048) but overflows a
// quarter of a 2GB budget (budget drops it to the conservative default).
const largeCtx = int32(32768)
node := &BackendNode{
GPUVendor: "nvidia",
GPUComputeCapability: "12.1",
TotalVRAM: 64 * gb,
VRAMBudgetBytes: 2 * gb, // tiny operator budget
}
opts := &pb.ModelOptions{NBatch: int32(config.BlackwellPhysicalBatch), ContextSize: largeCtx}
applyNodeHardwareDefaults(opts, node, "llama-cpp")
// With only 2GB budgeted the raised 2048 batch must not survive.
Expect(int(opts.NBatch)).To(BeNumerically("<", config.BlackwellPhysicalBatch))
})
})

View File

@@ -61,6 +61,11 @@ type Config struct {
// the existing label selector.
MaxReplicasPerModel int `env:"LOCALAI_MAX_REPLICAS_PER_MODEL" default:"1" help:"Max replicas of any single model on this worker. Default 1 preserves single-replica behavior; set higher to allow stacking replicas on a fat node." group:"registration"`
// VRAMBudget optionally caps this node's VRAM for model allocation ("80%" or
// "12GB"). Reported to the server as a string; the server resolves and
// enforces it against the raw VRAM this worker reports. Empty = no cap.
VRAMBudget string `env:"LOCALAI_VRAM_BUDGET" help:"Cap VRAM used for model allocation on this worker node, as a percentage (e.g. 80%) or absolute amount (e.g. 12GB)." group:"registration"`
// NATS (required)
NatsURL string `env:"LOCALAI_NATS_URL" required:"" help:"NATS server URL" group:"distributed"`
NatsJWT string `env:"LOCALAI_NATS_JWT" help:"NATS user JWT override (normally from registration nats_jwt)" group:"distributed"`

View File

@@ -93,6 +93,14 @@ func (cfg *Config) registrationBody() map[string]any {
"max_replicas_per_model": maxReplicas,
}
// Report the operator-set budget as a STRING so the server resolves and
// enforces it against the raw VRAM above. The worker never caps its own
// total_vram/available_vram, and never touches the xsysinfo process-global
// budget (that is standalone-only). Omit when unset.
if cfg.VRAMBudget != "" {
body["vram_budget"] = cfg.VRAMBudget
}
// If no GPU detected, report system RAM so the scheduler/UI has capacity info
if totalVRAM == 0 {
if ramInfo, err := xsysinfo.GetSystemRAMInfo(); err == nil {

View File

@@ -0,0 +1,33 @@
package worker
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/pkg/xsysinfo"
)
var _ = Describe("Worker registration body", func() {
It("includes the VRAM budget in the registration body when set", func() {
cfg := &Config{VRAMBudget: "80%"}
body := cfg.registrationBody()
Expect(body["vram_budget"]).To(Equal("80%"))
})
It("omits an empty VRAM budget", func() {
cfg := &Config{}
body := cfg.registrationBody()
_, present := body["vram_budget"]
Expect(present).To(BeFalse())
})
It("reports raw total_vram regardless of the budget", func() {
// The worker reports RAW VRAM; the server resolves and enforces the
// budget. It must never pre-cap total_vram locally, so total_vram equals
// the raw xsysinfo reading whether or not a budget is set.
rawTotal, _ := xsysinfo.TotalAvailableVRAM()
cfg := &Config{VRAMBudget: "50%"}
body := cfg.registrationBody()
Expect(body["total_vram"]).To(Equal(rawTotal))
})
})

View File

@@ -360,6 +360,49 @@ This configuration:
- Waits 2 seconds between retry attempts
- Ensures busy models have time to complete their requests before eviction
## VRAM Budget (Allocation Ceiling)
By default LocalAI treats **all** detected GPU memory as available for model allocation. On a shared machine (a desktop you also game on, a box that runs other GPU workloads, or a multi-tenant server) you often want to reserve some headroom and let LocalAI use only part of the card. The **VRAM budget** sets a hard ceiling on how much VRAM LocalAI will consider allocatable.
### Configuration
Set the budget with a CLI flag or environment variable:
```bash
# Percentage of detected VRAM
./local-ai --vram-budget=80%
# Absolute amount (GB, GiB, MB, or raw bytes)
./local-ai --vram-budget=12GB
# Using environment variables
LOCALAI_VRAM_BUDGET=80% ./local-ai
LOCALAI_VRAM_BUDGET=12GB ./local-ai
```
Accepted formats:
- A **percentage** such as `80%` (or the decimal `0.8`).
- An **absolute amount** such as `12GB`, `12GiB`, `12000MB`, or a raw byte count.
Leaving it empty or unset (the default) uses all detected VRAM, which is the historical behavior.
### Semantics
The budget is a **hard ceiling**, never a floor:
- Everywhere LocalAI reads VRAM to decide model allocation (hardware defaults for batch size and parallelism, context auto-fit, GGUF fit warnings, and the watchdog), it uses `min(detected VRAM, budget)`.
- The budget can only **lower** usable VRAM, never raise it above what the hardware physically has. An absolute value larger than physical VRAM is clamped to physical.
- A percentage above `100%` is rejected as invalid.
### Editing from the UI
The budget is exposed in the **Settings** page under the **Performance** section as **VRAM Budget**, so you can change it without editing flags or restarting from scratch. It is live-editable and persists to the runtime settings.
### Distributed mode
The same `LOCALAI_VRAM_BUDGET` / `--vram-budget` applies to distributed worker nodes (`local-ai worker ...`), where it caps the VRAM the scheduler will use for per-node placement. Admins can also set a per-node budget live from the node UI (or the admin API); see [Distributed Mode]({{%relref "features/distributed-mode#per-node-vram-budget" %}}).
## Limitations and Considerations
### VRAM Usage Estimation

View File

@@ -220,6 +220,7 @@ local-ai worker \
| `--nats-tls-key` | `LOCALAI_NATS_TLS_KEY` | *(empty)* | Client private key for NATS mTLS |
| `--backends-path` | `LOCALAI_BACKENDS_PATH` | `./backends` | Path to backend binaries |
| `--models-path` | `LOCALAI_MODELS_PATH` | `./models` | Path to model files |
| `--vram-budget` | `LOCALAI_VRAM_BUDGET` | *(empty)* | Cap the VRAM this node advertises for model placement, as a percentage (e.g. `80%`) or an absolute amount (e.g. `12GB`). Empty uses all detected VRAM. See [Per-node VRAM budget](#per-node-vram-budget). |
{{% notice tip %}}
**Advertise address:** The `--addr` flag is the local bind address for gRPC. The `--advertise-addr` is the address the frontend stores and uses to reach the worker via gRPC. If not set, the worker auto-derives it by replacing `0.0.0.0` with the OS hostname (which in Docker is the container ID, resolvable via Docker DNS). Set `--advertise-addr` explicitly when the auto-detected hostname is not routable from the frontend (e.g., in Kubernetes, use the pod's service DNS name).
@@ -337,9 +338,41 @@ Used by the WebUI and admin API consumers. Requires admin authentication.
| `POST` | `/api/nodes/:id/backends/delete` | Delete a backend from a worker |
| `POST` | `/api/nodes/:id/models/unload` | Unload a model from a worker |
| `POST` | `/api/nodes/:id/models/delete` | Delete model files from a worker |
| `PUT` | `/api/nodes/:id/vram-budget` | Set a VRAM budget for a worker (`{"value":"80%"}`) |
| `DELETE` | `/api/nodes/:id/vram-budget` | Clear a worker's VRAM budget (revert to all detected VRAM) |
The **Nodes** page in the React WebUI provides a visual overview of all registered workers, their statuses, and loaded models. The page opens with a one-line **cluster pulse** summarising node health and an **attention callout** that surfaces nodes needing action (for example pending approvals). Below that, a roster of **node panels** lists each worker with its inline model chips (no expand click needed), filtered by an **All / Backend / Agent** segmented control. Selecting a panel opens a dedicated **node detail page** at `/app/nodes/:id` with per-node metrics, models, and backend actions. Model scheduling lives on its own **Scheduling** page (separate nav item), not as a tab on the Nodes page.
### Per-node VRAM budget
Each worker advertises its detected VRAM, and the SmartRouter uses that number when picking a node with enough free memory. You can cap the VRAM a node offers for placement so it never gets scheduled beyond a chosen limit, leaving headroom for other workloads on that machine.
There are two ways to set the cap:
- **At the worker:** start it with `--vram-budget` / `LOCALAI_VRAM_BUDGET` (see [Worker Configuration](#worker-configuration)).
- **From the frontend, live:** set it per node in the **node capacity editor** on the node detail page, or via the admin API:
```bash
# Cap node placement at 80% of its detected VRAM
curl -X PUT http://frontend:8080/api/nodes/<node-id>/vram-budget \
-H "Authorization: Bearer <admin-token>" \
-H "Content-Type: application/json" \
-d '{"value":"80%"}'
# Or an absolute amount
curl -X PUT http://frontend:8080/api/nodes/<node-id>/vram-budget \
-H "Authorization: Bearer <admin-token>" \
-d '{"value":"12GB"}'
# Clear the budget (revert to all detected VRAM)
curl -X DELETE http://frontend:8080/api/nodes/<node-id>/vram-budget \
-H "Authorization: Bearer <admin-token>"
```
The value accepts the same formats as the standalone budget: a percentage (`80%`) or an absolute amount (`12GB`, `12GiB`, `12000MB`, or raw bytes). It is a **hard ceiling**: the node's advertised VRAM becomes `min(detected, budget)`, so a budget can only lower the number, never raise it above the hardware. An admin-set node budget is **sticky across worker restarts**: it is stored in the node registry and reapplied when the worker re-registers, so it wins over whatever the worker reports on reconnect. For the underlying semantics and the standalone equivalent, see [VRAM Budget]({{%relref "advanced/vram-management#vram-budget-allocation-ceiling" %}}).
The **LocalAI Assistant** can also set a node budget conversationally through the `set_node_vram_budget` MCP tool.
## Node Approval
By default, new worker nodes start in **pending** status and must be approved by an admin before they can receive traffic. This prevents unknown machines from joining the cluster.

View File

@@ -64,6 +64,10 @@ type LocalAIClient interface {
// ---- System ----
SystemInfo(ctx context.Context) (*SystemInfo, error)
ListNodes(ctx context.Context) ([]Node, error)
// SetNodeVRAMBudget sets (or, with an empty budget, clears) a federated
// node's VRAM allocation cap as a sticky admin override. Only meaningful
// in distributed mode; single-process clients report it as unavailable.
SetNodeVRAMBudget(ctx context.Context, nodeID, budget string) error
VRAMEstimate(ctx context.Context, req VRAMEstimateRequest) (*vram.EstimateResult, error)
// ---- State ----

View File

@@ -59,6 +59,7 @@ var toolToHTTPRoute = map[string]string{
ToolSetAlias: "PATCH /api/models/config-json/:name (swap) or POST /models/import (create)",
ToolCreateVoiceProfile: "POST /api/voice-profiles",
ToolDeleteVoiceProfile: "DELETE /api/voice-profiles/:id",
ToolSetNodeVRAMBudget: "PUT /api/nodes/:id/vram-budget",
}
// allKnownTools is the union of expectedFullCatalog (defined in

View File

@@ -105,6 +105,15 @@ type Node struct {
LastSeen string `json:"last_seen,omitempty"`
}
// SetNodeVRAMBudgetRequest is the input for set_node_vram_budget. It PUTs
// the value to /api/nodes/{node_id}/vram-budget, where the server validates
// and resolves the budget against the node's total VRAM. An empty Budget
// clears the admin override so the worker's own default takes over again.
type SetNodeVRAMBudgetRequest struct {
NodeID string `json:"node_id" jsonschema:"The federated node id (from list_nodes) whose VRAM budget to set."`
Budget string `json:"budget,omitempty" jsonschema:"VRAM allocation cap as a percentage (e.g. 80%) or absolute amount (e.g. 12GB). Empty string clears the override."`
}
// ImportModelURIRequest is the input for import_model_uri. It mirrors the
// REST surface (`/models/import-uri`) closely so both clients can produce
// identical responses; the BackendPreference is a flat field rather than the

View File

@@ -42,6 +42,7 @@ type fakeClient struct {
upgradeBackend func(string) (string, error)
systemInfo func() (*SystemInfo, error)
listNodes func() ([]Node, error)
setNodeVRAMBudget func(string, string) error
vramEstimate func(VRAMEstimateRequest) (*vram.EstimateResult, error)
toggleModelState func(string, modeladmin.Action) error
toggleModelPinned func(string, modeladmin.Action) error
@@ -229,6 +230,14 @@ func (f *fakeClient) ListNodes(_ context.Context) ([]Node, error) {
return nil, nil
}
func (f *fakeClient) SetNodeVRAMBudget(_ context.Context, nodeID, budget string) error {
f.record("SetNodeVRAMBudget", []any{nodeID, budget})
if f.setNodeVRAMBudget != nil {
return f.setNodeVRAMBudget(nodeID, budget)
}
return nil
}
func (f *fakeClient) VRAMEstimate(_ context.Context, req VRAMEstimateRequest) (*vram.EstimateResult, error) {
f.record("VRAMEstimate", req)
if f.vramEstimate != nil {

View File

@@ -480,6 +480,13 @@ func (c *Client) ListNodes(ctx context.Context) ([]localaitools.Node, error) {
return out, nil
}
func (c *Client) SetNodeVRAMBudget(ctx context.Context, nodeID, budget string) error {
// PUT with an empty value clears the override server-side (Task 9), so we
// use PUT uniformly rather than switching to DELETE for the clear case.
body := map[string]any{"value": budget}
return c.do(ctx, http.MethodPut, routeNodeVRAMBudget(nodeID), body, nil)
}
func (c *Client) VRAMEstimate(ctx context.Context, req localaitools.VRAMEstimateRequest) (*vram.EstimateResult, error) {
body := map[string]any{"model": req.ModelName}
if req.ContextSize > 0 {

View File

@@ -62,3 +62,7 @@ func routeToggleModelPinned(name, action string) string {
func routeVoiceProfileDelete(id string) string {
return "/api/voice-profiles/" + url.PathEscape(id)
}
func routeNodeVRAMBudget(id string) string {
return "/api/nodes/" + url.PathEscape(id) + "/vram-budget"
}

View File

@@ -500,6 +500,14 @@ func (c *Client) ListNodes(_ context.Context) ([]localaitools.Node, error) {
return []localaitools.Node{}, nil
}
func (c *Client) SetNodeVRAMBudget(_ context.Context, _, _ string) error {
// The node registry is a distributed-mode concern owned by the Application
// layer and is not wired into the in-process client (which also returns an
// empty node list from ListNodes). Report it as unavailable rather than
// silently succeeding so the assistant tells the admin the truth.
return errors.New("per-node VRAM budgets are only available in distributed mode")
}
func (c *Client) VRAMEstimate(ctx context.Context, req localaitools.VRAMEstimateRequest) (*vram.EstimateResult, error) {
resp, err := modeladmin.EstimateVRAM(ctx, modeladmin.VRAMRequest{
Model: req.ModelName,

View File

@@ -104,6 +104,7 @@ var expectedFullCatalog = sortedStrings(
ToolVRAMEstimate,
ToolCreateVoiceProfile,
ToolDeleteVoiceProfile,
ToolSetNodeVRAMBudget,
)
// expectedReadOnlyCatalog is the tool set when DisableMutating=true. Sorted.

View File

@@ -41,6 +41,7 @@ const (
ToolSetAlias = "set_alias"
ToolCreateVoiceProfile = "create_voice_profile"
ToolDeleteVoiceProfile = "delete_voice_profile"
ToolSetNodeVRAMBudget = "set_node_vram_budget"
// ToolListAliases is read-only but lives here so the alias tools stay
// grouped; the catalog tests assert its read-only placement.

View File

@@ -6,7 +6,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func registerSystemTools(s *mcp.Server, client LocalAIClient, _ Options) {
func registerSystemTools(s *mcp.Server, client LocalAIClient, opts Options) {
mcp.AddTool(s, &mcp.Tool{
Name: ToolSystemInfo,
Description: "Report LocalAI version, paths, distributed flag, currently loaded models, and installed backends.",
@@ -28,4 +28,21 @@ func registerSystemTools(s *mcp.Server, client LocalAIClient, _ Options) {
}
return jsonResult(nodes), nil, nil
})
if opts.DisableMutating {
return
}
mcp.AddTool(s, &mcp.Tool{
Name: ToolSetNodeVRAMBudget,
Description: "Set a federated node's VRAM allocation budget (\"80%\" or \"12GB\"; empty clears the override). Only meaningful in distributed mode. Requires user confirmation per safety rule 1.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, args SetNodeVRAMBudgetRequest) (*mcp.CallToolResult, any, error) {
if args.NodeID == "" {
return errorResultf("node_id is required"), nil, nil
}
if err := client.SetNodeVRAMBudget(ctx, args.NodeID, args.Budget); err != nil {
return errorResult(err), nil, nil
}
return jsonResult(map[string]string{"node_id": args.NodeID, "vram_budget": args.Budget}), nil, nil
})
}

158
pkg/vrambudget/budget.go Normal file
View File

@@ -0,0 +1,158 @@
// 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"
}

View File

@@ -0,0 +1,102 @@
package vrambudget_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/pkg/vrambudget"
)
const gb = uint64(1000 * 1000 * 1000)
var _ = Describe("Budget", func() {
Describe("Parse", func() {
It("treats empty/zero as unset (no error)", func() {
for _, s := range []string{"", " ", "0", "0%", "0b"} {
b, err := vrambudget.Parse(s)
Expect(err).NotTo(HaveOccurred())
Expect(b.IsSet()).To(BeFalse(), "input %q", s)
}
})
It("parses percentage forms", func() {
for _, s := range []string{"80%", "0.8"} {
b, err := vrambudget.Parse(s)
Expect(err).NotTo(HaveOccurred())
Expect(b.IsSet()).To(BeTrue())
total, free := b.Apply(10*gb, 10*gb)
Expect(total).To(Equal(8 * gb))
Expect(free).To(Equal(8 * gb))
}
})
It("treats 100% / 1.0 as a valid no-op ceiling", func() {
b, err := vrambudget.Parse("100%")
Expect(err).NotTo(HaveOccurred())
total, free := b.Apply(10*gb, 6*gb)
Expect(total).To(Equal(10 * gb))
Expect(free).To(Equal(6 * gb))
})
It("parses absolute forms with decimal and binary suffixes and raw bytes", func() {
cases := map[string]uint64{
"12GB": 12 * gb,
"12000MB": 12 * gb,
"12000000000": 12 * gb,
"12GiB": 12 * 1024 * 1024 * 1024,
}
for s, want := range cases {
b, err := vrambudget.Parse(s)
Expect(err).NotTo(HaveOccurred(), "input %q", s)
Expect(b.Ceiling(64 * gb)).To(Equal(want), "input %q", s)
}
})
It("rejects malformed and out-of-range input", func() {
for _, s := range []string{"120%", "1.5", "-1", "abc", "12ZB", "%", "12 GB x"} {
_, err := vrambudget.Parse(s)
Expect(err).To(HaveOccurred(), "input %q", s)
}
})
})
Describe("Apply", func() {
It("is a passthrough when unset", func() {
b, _ := vrambudget.Parse("")
total, free := b.Apply(16*gb, 9*gb)
Expect(total).To(Equal(16 * gb))
Expect(free).To(Equal(9 * gb))
})
It("caps total and free to the budget ceiling (hard ceiling)", func() {
b, _ := vrambudget.Parse("12GB")
total, free := b.Apply(16*gb, 15*gb)
Expect(total).To(Equal(12 * gb))
Expect(free).To(Equal(12 * gb))
})
It("never raises free above detected when free is already below budget", func() {
b, _ := vrambudget.Parse("12GB")
_, free := b.Apply(16*gb, 5*gb)
Expect(free).To(Equal(5 * gb))
})
It("clamps an absolute budget above physical to detected", func() {
b, _ := vrambudget.Parse("64GB")
total, free := b.Apply(16*gb, 10*gb)
Expect(total).To(Equal(16 * gb))
Expect(free).To(Equal(10 * gb))
})
})
Describe("String", func() {
It("round-trips percentage and absolute canonical forms", func() {
p, _ := vrambudget.Parse("80%")
Expect(p.String()).To(Equal("80%"))
a, _ := vrambudget.Parse("12000MB")
Expect(a.String()).To(Equal("12GB"))
z, _ := vrambudget.Parse("")
Expect(z.String()).To(Equal(""))
})
})
})

View File

@@ -0,0 +1,13 @@
package vrambudget_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestVRAMBudget(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "VRAMBudget test suite")
}

View File

@@ -10,12 +10,37 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"github.com/jaypipes/ghw"
"github.com/jaypipes/ghw/pkg/gpu"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/xlog"
)
// defaultVRAMBudget is the process-wide allocation cap set by standalone
// local-ai (LOCALAI_VRAM_BUDGET / the Settings page). nil pointer = no cap.
// The aggregate getters below apply it before returning so every allocation
// decision (hardware defaults, context-fit, watchdog) inherits the cap without
// per-call-site changes. Distributed workers deliberately leave this unset and
// report raw VRAM; the server applies per-node budgets instead.
var defaultVRAMBudget atomic.Pointer[vrambudget.Budget]
// SetDefaultVRAMBudget installs the process-wide VRAM allocation cap. Safe to
// call at startup and again live when the Settings page changes it.
func SetDefaultVRAMBudget(b vrambudget.Budget) {
bb := b
defaultVRAMBudget.Store(&bb)
}
// DefaultVRAMBudget returns the current process-wide cap (unset Budget = none).
func DefaultVRAMBudget() vrambudget.Budget {
if p := defaultVRAMBudget.Load(); p != nil {
return *p
}
return vrambudget.Budget{}
}
// GPU vendor constants
const (
VendorNVIDIA = "nvidia"
@@ -107,7 +132,8 @@ func TotalAvailableVRAM() (uint64, error) {
}
// If we got valid VRAM from ghw, return it
if totalVRAM > 0 {
return totalVRAM, nil
capped, _ := DefaultVRAMBudget().Apply(totalVRAM, totalVRAM)
return capped, nil
}
}
@@ -121,7 +147,8 @@ func TotalAvailableVRAM() (uint64, error) {
}
if totalVRAM > 0 {
xlog.Debug("VRAM detected via binary tools", "total_vram", totalVRAM)
return totalVRAM, nil
capped, _ := DefaultVRAMBudget().Apply(totalVRAM, totalVRAM)
return capped, nil
}
}
@@ -144,7 +171,8 @@ func MinPerGPUVRAM() (uint64, error) {
// hosts, which is why TotalAvailableVRAM treats it as a sum.
if infos := GetGPUMemoryUsage(); len(infos) > 0 {
if v := minNonZeroVRAM(infos); v > 0 {
return v, nil
capped, _ := DefaultVRAMBudget().Apply(v, v)
return capped, nil
}
}
@@ -162,7 +190,8 @@ func MinPerGPUVRAM() (uint64, error) {
}
}
if min > 0 {
return min, nil
capped, _ := DefaultVRAMBudget().Apply(min, min)
return capped, nil
}
}
@@ -359,6 +388,16 @@ func GetGPUAggregateInfo() GPUAggregateInfo {
aggregate.UsagePercent = float64(aggregate.UsedVRAM) / float64(aggregate.TotalVRAM) * 100
}
// Apply the process-wide allocation cap so scheduling/hardware-default
// consumers see budgeted capacity, not physical capacity.
if b := DefaultVRAMBudget(); b.IsSet() && aggregate.TotalVRAM > 0 {
aggregate.TotalVRAM, aggregate.FreeVRAM = b.Apply(aggregate.TotalVRAM, aggregate.FreeVRAM)
aggregate.UsedVRAM = aggregate.TotalVRAM - aggregate.FreeVRAM
if aggregate.TotalVRAM > 0 {
aggregate.UsagePercent = float64(aggregate.UsedVRAM) / float64(aggregate.TotalVRAM) * 100
}
}
return aggregate
}
@@ -971,8 +1010,15 @@ func GetResourceInfo() ResourceInfo {
// GetResourceAggregateInfo returns aggregate memory info (GPU if available, otherwise RAM)
// This is used by the memory reclaimer to check memory usage
func GetResourceAggregateInfo() AggregateMemoryInfo {
resourceInfo := GetResourceInfo()
return resourceInfo.Aggregate
// The VRAM budget is applied exactly once, upstream: the GPU-branch
// Aggregate is sourced from GetGPUAggregateInfo, which already caps
// total/free/used against DefaultVRAMBudget. Re-applying b.Apply here would
// double-cap: Apply resolves a percentage budget as a fraction of its input
// total, so a second pass shrinks the already-shrunk total again (P*(P*T)
// instead of P*T) and corrupts UsagePercent read by the memory reclaimer.
// The RAM branch is never budgeted (the budget is VRAM-only). So this
// boundary must return the aggregate unchanged.
return GetResourceInfo().Aggregate
}
// getVulkanGPUMemory queries GPUs using vulkaninfo as a fallback.

View File

@@ -0,0 +1,26 @@
package xsysinfo_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/pkg/vrambudget"
"github.com/mudler/LocalAI/pkg/xsysinfo"
)
var _ = Describe("Default VRAM budget", func() {
AfterEach(func() {
xsysinfo.SetDefaultVRAMBudget(vrambudget.Budget{}) // reset to no cap
})
It("defaults to unset", func() {
Expect(xsysinfo.DefaultVRAMBudget().IsSet()).To(BeFalse())
})
It("stores and returns a set budget", func() {
b, _ := vrambudget.Parse("80%")
xsysinfo.SetDefaultVRAMBudget(b)
Expect(xsysinfo.DefaultVRAMBudget().IsSet()).To(BeTrue())
Expect(xsysinfo.DefaultVRAMBudget().String()).To(Equal("80%"))
})
})