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>
This commit is contained in:
localai-org-maint-bot
2026-07-28 23:55:35 +02:00
committed by GitHub
parent 996bdcecdc
commit 034df6ceb1
7 changed files with 98 additions and 22 deletions

View File

@@ -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)

View File

@@ -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' },

View File

@@ -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 (
<div className="node-panel">
@@ -45,6 +46,9 @@ export default function NodePanel({ node, models = [], onApprove, onDrain, onRes
{node.total_vram > 0 && (
<span className="cell-mono">VRAM {formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}</span>
)}
{node.total_ram > 0 && (
<span className="cell-mono">RAM {formatVRAM(usedRAM) || '0'} / {formatVRAM(node.total_ram)}</span>
)}
<span className="cell-mono">{node.in_flight_count || 0} in-flight</span>
</div>
<div className="node-panel__models">

View File

@@ -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. */}
<div className="node-detail__metrics">
{node.total_vram > 0 && (
<div>
@@ -96,6 +97,12 @@ export default function NodeDetail() {
<span className="cell-mono">{formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}</span>
</div>
)}
{node.total_ram > 0 && (
<div>
<div className="drawer-eyebrow">RAM</div>
<span className="cell-mono">{formatVRAM(usedRAM) || '0'} / {formatVRAM(node.total_ram)}</span>
</div>
)}
{node.total_disk > 0 && (
<div>
{/* Free space on the worker's MODELS filesystem. A node can look

View File

@@ -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

View File

@@ -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)))
})
})

View File

@@ -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