mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
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:
committed by
GitHub
parent
996bdcecdc
commit
034df6ceb1
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user