Skip to content

Commit 034df6c

Browse files
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>
1 parent 996bdce commit 034df6c

7 files changed

Lines changed: 98 additions & 22 deletions

File tree

core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ test.describe('Nodes page — per-node backend actions', () => {
102102
await mockDistributedNodes(page)
103103
await openNodeDetail(page)
104104

105+
await expect(page.locator('.node-detail__metrics')).toContainText('RAM')
106+
await expect(page.locator('.node-detail__metrics')).toContainText('3.7 GB / 7.5 GB')
107+
105108
// Negative: the old, ambiguous wording must not be used.
106109
await expect(page.locator('button[title="Reinstall backend"]')).toHaveCount(0)
107110
await expect(page.locator('button[title="Reinstall backend"] i.fa-sync-alt')).toHaveCount(0)

core/http/react-ui/e2e/nodes-roster.spec.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ test.describe('Nodes roster header', () => {
2626
})
2727

2828
test.describe('Nodes roster panels', () => {
29+
test('shows used and total system RAM reported by a worker', async ({ page }) => {
30+
await mockCluster(page, [
31+
{
32+
id: 'n1',
33+
name: 'alpha',
34+
node_type: 'backend',
35+
address: '10.0.0.1:50051',
36+
status: 'healthy',
37+
total_ram: 8_000_000_000,
38+
available_ram: 3_000_000_000,
39+
},
40+
])
41+
42+
await page.goto('/app/nodes')
43+
await expect(page.locator('.node-panel').filter({ hasText: 'alpha' })).toContainText('RAM 4.7 GB / 7.5 GB', { timeout: 15_000 })
44+
})
45+
2946
test('shows model chips without clicking and filters by type', async ({ page }) => {
3047
await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
3148
{ id: 'n1', name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' },

core/http/react-ui/src/components/nodes/NodePanel.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default function NodePanel({ node, models = [], onApprove, onDrain, onRes
99
const isAgent = node.node_type === 'agent'
1010
const open = () => navigate(`/app/nodes/${node.id}`)
1111
const usedVRAM = node.total_vram && node.available_vram != null ? node.total_vram - node.available_vram : null
12+
const usedRAM = node.total_ram && node.available_ram != null ? node.total_ram - node.available_ram : null
1213

1314
return (
1415
<div className="node-panel">
@@ -45,6 +46,9 @@ export default function NodePanel({ node, models = [], onApprove, onDrain, onRes
4546
{node.total_vram > 0 && (
4647
<span className="cell-mono">VRAM {formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}</span>
4748
)}
49+
{node.total_ram > 0 && (
50+
<span className="cell-mono">RAM {formatVRAM(usedRAM) || '0'} / {formatVRAM(node.total_ram)}</span>
51+
)}
4852
<span className="cell-mono">{node.in_flight_count || 0} in-flight</span>
4953
</div>
5054
<div className="node-panel__models">

core/http/react-ui/src/pages/NodeDetail.jsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export default function NodeDetail() {
6464
const delLabel = async (k) => { try { await nodesApi.deleteLabel(id, k); refresh() } catch (e) { addToast(e.message, 'error') } }
6565

6666
const usedVRAM = node.total_vram && node.available_vram != null ? node.total_vram - node.available_vram : 0
67+
const usedRAM = node.total_ram && node.available_ram != null ? node.total_ram - node.available_ram : 0
6768
// {modelName: replicaCount} of loaded models so the shrink confirm can warn
6869
// if the new cap is below the actual count of any single model on this node.
6970
const loadedModelCounts = (() => {
@@ -88,14 +89,20 @@ export default function NodeDetail() {
8889
}
8990
/>
9091

91-
{/* Inline metrics row: VRAM / in-flight - no boxes, just labelled values. */}
92+
{/* Inline resource and activity metrics - no boxes, just labelled values. */}
9293
<div className="node-detail__metrics">
9394
{node.total_vram > 0 && (
9495
<div>
9596
<div className="drawer-eyebrow">VRAM</div>
9697
<span className="cell-mono">{formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}</span>
9798
</div>
9899
)}
100+
{node.total_ram > 0 && (
101+
<div>
102+
<div className="drawer-eyebrow">RAM</div>
103+
<span className="cell-mono">{formatVRAM(usedRAM) || '0'} / {formatVRAM(node.total_ram)}</span>
104+
</div>
105+
)}
99106
{node.total_disk > 0 && (
100107
<div>
101108
{/* Free space on the worker's MODELS filesystem. A node can look

core/services/worker/registration.go

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import (
1313
"github.com/mudler/xlog"
1414
)
1515

16+
var (
17+
totalAvailableVRAM = xsysinfo.TotalAvailableVRAM
18+
getGPUAggregateInfo = xsysinfo.GetGPUAggregateInfo
19+
getSystemRAMInfo = xsysinfo.GetSystemRAMInfo
20+
)
21+
1622
// effectiveBasePort returns the port used as base for gRPC backend processes.
1723
// Priority: Addr port → ServeAddr port → 50051
1824
func (cfg *Config) effectiveBasePort() int {
@@ -118,7 +124,7 @@ func (cfg *Config) registrationBody() map[string]any {
118124
}
119125

120126
// Detect GPU info for VRAM-aware scheduling
121-
totalVRAM, err := xsysinfo.TotalAvailableVRAM()
127+
totalVRAM, err := totalAvailableVRAM()
122128
if err != nil {
123129
xlog.Debug("Failed to detect worker VRAM; registering without GPU capacity", "error", err)
124130
}
@@ -179,15 +185,14 @@ func (cfg *Config) registrationBody() map[string]any {
179185
body["vram_budget"] = cfg.VRAMBudget
180186
}
181187

182-
// If no GPU detected, report system RAM so the scheduler/UI has capacity info
183-
if totalVRAM == 0 {
184-
ramInfo, err := xsysinfo.GetSystemRAMInfo()
185-
if err != nil {
186-
xlog.Debug("Failed to detect worker RAM for registration", "error", err)
187-
} else {
188-
body["total_ram"] = ramInfo.Total
189-
body["available_ram"] = ramInfo.Available
190-
}
188+
// Report system RAM independently from VRAM so both discrete-GPU and
189+
// unified-memory workers expose the capacity visible to the host.
190+
ramInfo, err := getSystemRAMInfo()
191+
if err != nil {
192+
xlog.Debug("Failed to detect worker RAM for registration", "error", err)
193+
} else {
194+
body["total_ram"] = ramInfo.Total
195+
body["available_ram"] = ramInfo.Available
191196
}
192197
if cfg.RegistrationToken != "" {
193198
body["token"] = cfg.RegistrationToken
@@ -220,20 +225,17 @@ func (cfg *Config) registrationBody() map[string]any {
220225
// free capacity.
221226
func (cfg *Config) heartbeatBody() map[string]any {
222227
body := map[string]any{}
223-
aggregate := xsysinfo.GetGPUAggregateInfo()
228+
aggregate := getGPUAggregateInfo()
224229
if aggregate.TotalVRAM > 0 {
225230
body["available_vram"] = aggregate.FreeVRAM
226231
}
227232

228-
// CPU-only workers (or workers that lost GPU visibility momentarily):
229-
// report system RAM so the scheduler still has capacity info.
230-
if aggregate.TotalVRAM == 0 {
231-
ramInfo, err := xsysinfo.GetSystemRAMInfo()
232-
if err != nil {
233-
xlog.Debug("Failed to detect worker RAM for heartbeat", "error", err)
234-
} else {
235-
body["available_ram"] = ramInfo.Available
236-
}
233+
// RAM availability changes independently from VRAM on discrete-GPU nodes.
234+
ramInfo, err := getSystemRAMInfo()
235+
if err != nil {
236+
xlog.Debug("Failed to detect worker RAM for heartbeat", "error", err)
237+
} else {
238+
body["available_ram"] = ramInfo.Available
237239
}
238240

239241
// Free disk changes far faster than VRAM under staging traffic (each

core/services/worker/registration_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@ import (
88
)
99

1010
var _ = Describe("Worker registration body", func() {
11+
BeforeEach(func() {
12+
originalTotalAvailableVRAM := totalAvailableVRAM
13+
originalGetGPUAggregateInfo := getGPUAggregateInfo
14+
originalGetSystemRAMInfo := getSystemRAMInfo
15+
DeferCleanup(func() {
16+
totalAvailableVRAM = originalTotalAvailableVRAM
17+
getGPUAggregateInfo = originalGetGPUAggregateInfo
18+
getSystemRAMInfo = originalGetSystemRAMInfo
19+
})
20+
})
21+
1122
It("includes the VRAM budget in the registration body when set", func() {
1223
cfg := &Config{VRAMBudget: "80%"}
1324
body := cfg.registrationBody()
@@ -30,4 +41,33 @@ var _ = Describe("Worker registration body", func() {
3041
body := cfg.registrationBody()
3142
Expect(body["total_vram"]).To(Equal(rawTotal))
3243
})
44+
45+
It("reports RAM when GPU memory is visible", func() {
46+
totalAvailableVRAM = func() (uint64, error) {
47+
return 24_000, nil
48+
}
49+
getSystemRAMInfo = func() (*xsysinfo.SystemRAMInfo, error) {
50+
return &xsysinfo.SystemRAMInfo{Total: 64_000, Available: 48_000}, nil
51+
}
52+
53+
body := (&Config{}).registrationBody()
54+
55+
Expect(body["total_vram"]).To(Equal(uint64(24_000)))
56+
Expect(body["total_ram"]).To(Equal(uint64(64_000)))
57+
Expect(body["available_ram"]).To(Equal(uint64(48_000)))
58+
})
59+
60+
It("reports current RAM in heartbeats when GPU memory is visible", func() {
61+
getGPUAggregateInfo = func() xsysinfo.GPUAggregateInfo {
62+
return xsysinfo.GPUAggregateInfo{TotalVRAM: 24_000, FreeVRAM: 12_000}
63+
}
64+
getSystemRAMInfo = func() (*xsysinfo.SystemRAMInfo, error) {
65+
return &xsysinfo.SystemRAMInfo{Total: 64_000, Available: 40_000}, nil
66+
}
67+
68+
body := (&Config{}).heartbeatBody()
69+
70+
Expect(body["available_vram"]).To(Equal(uint64(12_000)))
71+
Expect(body["available_ram"]).To(Equal(uint64(40_000)))
72+
})
3373
})

docs/content/features/distributed-mode.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,10 @@ usage is reported back to the frontend:
372372
share one physical RAM between CPU and GPU. LocalAI detects them via
373373
`/sys/devices/soc0/family` and `/sys/devices/soc0/soc_id` (no `nvidia-smi`
374374
required) and reports system-RAM figures as VRAM. Free VRAM therefore tracks
375-
`MemAvailable` in `/proc/meminfo`.
375+
`MemAvailable` in `/proc/meminfo`. Workers report RAM metrics independently
376+
from VRAM on every registration and heartbeat. On unified-memory nodes, the
377+
available RAM and available VRAM values should therefore track each other
378+
closely; on discrete-GPU nodes they can change independently.
376379

377380
### Node Labels
378381

0 commit comments

Comments
 (0)