diff --git a/core/http/react-ui/src/pages/NodeDetail.jsx b/core/http/react-ui/src/pages/NodeDetail.jsx
index fb7ac1eec..ea62c5ecf 100644
--- a/core/http/react-ui/src/pages/NodeDetail.jsx
+++ b/core/http/react-ui/src/pages/NodeDetail.jsx
@@ -64,6 +64,7 @@ export default function NodeDetail() {
const delLabel = async (k) => { try { await nodesApi.deleteLabel(id, k); refresh() } catch (e) { addToast(e.message, 'error') } }
const usedVRAM = node.total_vram && node.available_vram != null ? node.total_vram - node.available_vram : 0
+ const usedRAM = node.total_ram && node.available_ram != null ? node.total_ram - node.available_ram : 0
// {modelName: replicaCount} of loaded models so the shrink confirm can warn
// if the new cap is below the actual count of any single model on this node.
const loadedModelCounts = (() => {
@@ -88,7 +89,7 @@ export default function NodeDetail() {
}
/>
- {/* Inline metrics row: VRAM / in-flight - no boxes, just labelled values. */}
+ {/* Inline resource and activity metrics - no boxes, just labelled values. */}
{node.total_vram > 0 && (
@@ -96,6 +97,12 @@ export default function NodeDetail() {
{formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}
)}
+ {node.total_ram > 0 && (
+
+
RAM
+
{formatVRAM(usedRAM) || '0'} / {formatVRAM(node.total_ram)}
+
+ )}
{node.total_disk > 0 && (
{/* Free space on the worker's MODELS filesystem. A node can look
diff --git a/core/services/worker/registration.go b/core/services/worker/registration.go
index 186bcd1b0..29d88d56c 100644
--- a/core/services/worker/registration.go
+++ b/core/services/worker/registration.go
@@ -13,6 +13,12 @@ import (
"github.com/mudler/xlog"
)
+var (
+ totalAvailableVRAM = xsysinfo.TotalAvailableVRAM
+ getGPUAggregateInfo = xsysinfo.GetGPUAggregateInfo
+ getSystemRAMInfo = xsysinfo.GetSystemRAMInfo
+)
+
// effectiveBasePort returns the port used as base for gRPC backend processes.
// Priority: Addr port → ServeAddr port → 50051
func (cfg *Config) effectiveBasePort() int {
@@ -118,7 +124,7 @@ func (cfg *Config) registrationBody() map[string]any {
}
// Detect GPU info for VRAM-aware scheduling
- totalVRAM, err := xsysinfo.TotalAvailableVRAM()
+ totalVRAM, err := totalAvailableVRAM()
if err != nil {
xlog.Debug("Failed to detect worker VRAM; registering without GPU capacity", "error", err)
}
@@ -179,15 +185,14 @@ func (cfg *Config) registrationBody() map[string]any {
body["vram_budget"] = cfg.VRAMBudget
}
- // If no GPU detected, report system RAM so the scheduler/UI has capacity info
- if totalVRAM == 0 {
- ramInfo, err := xsysinfo.GetSystemRAMInfo()
- if err != nil {
- xlog.Debug("Failed to detect worker RAM for registration", "error", err)
- } else {
- body["total_ram"] = ramInfo.Total
- body["available_ram"] = ramInfo.Available
- }
+ // Report system RAM independently from VRAM so both discrete-GPU and
+ // unified-memory workers expose the capacity visible to the host.
+ ramInfo, err := getSystemRAMInfo()
+ if err != nil {
+ xlog.Debug("Failed to detect worker RAM for registration", "error", err)
+ } else {
+ body["total_ram"] = ramInfo.Total
+ body["available_ram"] = ramInfo.Available
}
if cfg.RegistrationToken != "" {
body["token"] = cfg.RegistrationToken
@@ -220,20 +225,17 @@ func (cfg *Config) registrationBody() map[string]any {
// free capacity.
func (cfg *Config) heartbeatBody() map[string]any {
body := map[string]any{}
- aggregate := xsysinfo.GetGPUAggregateInfo()
+ aggregate := getGPUAggregateInfo()
if aggregate.TotalVRAM > 0 {
body["available_vram"] = aggregate.FreeVRAM
}
- // CPU-only workers (or workers that lost GPU visibility momentarily):
- // report system RAM so the scheduler still has capacity info.
- if aggregate.TotalVRAM == 0 {
- ramInfo, err := xsysinfo.GetSystemRAMInfo()
- if err != nil {
- xlog.Debug("Failed to detect worker RAM for heartbeat", "error", err)
- } else {
- body["available_ram"] = ramInfo.Available
- }
+ // RAM availability changes independently from VRAM on discrete-GPU nodes.
+ ramInfo, err := getSystemRAMInfo()
+ if err != nil {
+ xlog.Debug("Failed to detect worker RAM for heartbeat", "error", err)
+ } else {
+ body["available_ram"] = ramInfo.Available
}
// Free disk changes far faster than VRAM under staging traffic (each
diff --git a/core/services/worker/registration_test.go b/core/services/worker/registration_test.go
index 0fe6d2e51..01787f626 100644
--- a/core/services/worker/registration_test.go
+++ b/core/services/worker/registration_test.go
@@ -8,6 +8,17 @@ import (
)
var _ = Describe("Worker registration body", func() {
+ BeforeEach(func() {
+ originalTotalAvailableVRAM := totalAvailableVRAM
+ originalGetGPUAggregateInfo := getGPUAggregateInfo
+ originalGetSystemRAMInfo := getSystemRAMInfo
+ DeferCleanup(func() {
+ totalAvailableVRAM = originalTotalAvailableVRAM
+ getGPUAggregateInfo = originalGetGPUAggregateInfo
+ getSystemRAMInfo = originalGetSystemRAMInfo
+ })
+ })
+
It("includes the VRAM budget in the registration body when set", func() {
cfg := &Config{VRAMBudget: "80%"}
body := cfg.registrationBody()
@@ -30,4 +41,33 @@ var _ = Describe("Worker registration body", func() {
body := cfg.registrationBody()
Expect(body["total_vram"]).To(Equal(rawTotal))
})
+
+ It("reports RAM when GPU memory is visible", func() {
+ totalAvailableVRAM = func() (uint64, error) {
+ return 24_000, nil
+ }
+ getSystemRAMInfo = func() (*xsysinfo.SystemRAMInfo, error) {
+ return &xsysinfo.SystemRAMInfo{Total: 64_000, Available: 48_000}, nil
+ }
+
+ body := (&Config{}).registrationBody()
+
+ Expect(body["total_vram"]).To(Equal(uint64(24_000)))
+ Expect(body["total_ram"]).To(Equal(uint64(64_000)))
+ Expect(body["available_ram"]).To(Equal(uint64(48_000)))
+ })
+
+ It("reports current RAM in heartbeats when GPU memory is visible", func() {
+ getGPUAggregateInfo = func() xsysinfo.GPUAggregateInfo {
+ return xsysinfo.GPUAggregateInfo{TotalVRAM: 24_000, FreeVRAM: 12_000}
+ }
+ getSystemRAMInfo = func() (*xsysinfo.SystemRAMInfo, error) {
+ return &xsysinfo.SystemRAMInfo{Total: 64_000, Available: 40_000}, nil
+ }
+
+ body := (&Config{}).heartbeatBody()
+
+ Expect(body["available_vram"]).To(Equal(uint64(12_000)))
+ Expect(body["available_ram"]).To(Equal(uint64(40_000)))
+ })
})
diff --git a/docs/content/features/distributed-mode.md b/docs/content/features/distributed-mode.md
index e989866d8..f3a0f6534 100644
--- a/docs/content/features/distributed-mode.md
+++ b/docs/content/features/distributed-mode.md
@@ -372,7 +372,10 @@ usage is reported back to the frontend:
share one physical RAM between CPU and GPU. LocalAI detects them via
`/sys/devices/soc0/family` and `/sys/devices/soc0/soc_id` (no `nvidia-smi`
required) and reports system-RAM figures as VRAM. Free VRAM therefore tracks
-`MemAvailable` in `/proc/meminfo`.
+`MemAvailable` in `/proc/meminfo`. Workers report RAM metrics independently
+from VRAM on every registration and heartbeat. On unified-memory nodes, the
+available RAM and available VRAM values should therefore track each other
+closely; on discrete-GPU nodes they can change independently.
### Node Labels