From 034df6ceb1ca8e3d48ac28db47d626b719ee3214 Mon Sep 17 00:00:00 2001 From: localai-org-maint-bot Date: Tue, 28 Jul 2026 23:55:35 +0200 Subject: [PATCH] fix(worker): report RAM alongside GPU memory (#11167) * fix(worker): report RAM alongside GPU memory Assisted-by: Codex:gpt-5 * feat(ui): show worker RAM on node views Assisted-by: Codex:gpt-5 --------- Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com> --- .../nodes-per-node-backend-actions.spec.js | 3 ++ core/http/react-ui/e2e/nodes-roster.spec.js | 17 ++++++++ .../src/components/nodes/NodePanel.jsx | 4 ++ core/http/react-ui/src/pages/NodeDetail.jsx | 9 +++- core/services/worker/registration.go | 42 ++++++++++--------- core/services/worker/registration_test.go | 40 ++++++++++++++++++ docs/content/features/distributed-mode.md | 5 ++- 7 files changed, 98 insertions(+), 22 deletions(-) diff --git a/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js b/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js index 9ad92932c..33d871442 100644 --- a/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js +++ b/core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js @@ -102,6 +102,9 @@ test.describe('Nodes page — per-node backend actions', () => { await mockDistributedNodes(page) await openNodeDetail(page) + await expect(page.locator('.node-detail__metrics')).toContainText('RAM') + await expect(page.locator('.node-detail__metrics')).toContainText('3.7 GB / 7.5 GB') + // Negative: the old, ambiguous wording must not be used. await expect(page.locator('button[title="Reinstall backend"]')).toHaveCount(0) await expect(page.locator('button[title="Reinstall backend"] i.fa-sync-alt')).toHaveCount(0) diff --git a/core/http/react-ui/e2e/nodes-roster.spec.js b/core/http/react-ui/e2e/nodes-roster.spec.js index 861b94441..c6396d5b3 100644 --- a/core/http/react-ui/e2e/nodes-roster.spec.js +++ b/core/http/react-ui/e2e/nodes-roster.spec.js @@ -26,6 +26,23 @@ test.describe('Nodes roster header', () => { }) test.describe('Nodes roster panels', () => { + test('shows used and total system RAM reported by a worker', async ({ page }) => { + await mockCluster(page, [ + { + id: 'n1', + name: 'alpha', + node_type: 'backend', + address: '10.0.0.1:50051', + status: 'healthy', + total_ram: 8_000_000_000, + available_ram: 3_000_000_000, + }, + ]) + + await page.goto('/app/nodes') + await expect(page.locator('.node-panel').filter({ hasText: 'alpha' })).toContainText('RAM 4.7 GB / 7.5 GB', { timeout: 15_000 }) + }) + test('shows model chips without clicking and filters by type', async ({ page }) => { await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' }, diff --git a/core/http/react-ui/src/components/nodes/NodePanel.jsx b/core/http/react-ui/src/components/nodes/NodePanel.jsx index 0a2b76578..623db0093 100644 --- a/core/http/react-ui/src/components/nodes/NodePanel.jsx +++ b/core/http/react-ui/src/components/nodes/NodePanel.jsx @@ -9,6 +9,7 @@ export default function NodePanel({ node, models = [], onApprove, onDrain, onRes const isAgent = node.node_type === 'agent' const open = () => navigate(`/app/nodes/${node.id}`) const usedVRAM = node.total_vram && node.available_vram != null ? node.total_vram - node.available_vram : null + const usedRAM = node.total_ram && node.available_ram != null ? node.total_ram - node.available_ram : null return (
@@ -45,6 +46,9 @@ export default function NodePanel({ node, models = [], onApprove, onDrain, onRes {node.total_vram > 0 && ( VRAM {formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)} )} + {node.total_ram > 0 && ( + RAM {formatVRAM(usedRAM) || '0'} / {formatVRAM(node.total_ram)} + )} {node.in_flight_count || 0} in-flight
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