Files
llmgw/internal/agent/discovery/load.go
T
Ilker ManapandClaude Opus 5.5 daed835d88 Measure each node's CPU and memory, and give every node a page
The nodes are CPU machines, and nothing measured how busy one was: the
heartbeat carried GPU memory and disk, and llmgw_node_memory_bytes is the
total. The agent now reads /proc between heartbeats - CPU across all
cores, load averages, memory used and available, and each engine's
process group's cores and resident memory - and sends it with the
heartbeat. Nothing new runs on the nodes and Prometheus needs no new
target: the control plane exports it with the rest of the fleet, as
llmgw_node_cpu_utilization_percent, llmgw_node_cpus, llmgw_node_load,
llmgw_node_memory_used_bytes, llmgw_node_memory_available_bytes,
llmgw_shard_cpu_cores and llmgw_shard_memory_bytes. A measurement more
than a minute old is left out rather than repeated.

The cluster page's node table shows CPU and RAM as bars, and each node
links to a page of its own: current CPU, RAM, load and disk, the engines
on it with their cores and memory, and an hour of CPU, RAM, load,
per-model cores and memory and queue depth from Prometheus. It refreshes
in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:23 +02:00

261 lines
6.4 KiB
Go

package discovery
import (
"bufio"
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Load is how busy a node is: the machine as a whole, and each engine's share.
type Load struct {
CPUPercent float64
CPUCount int
Load1 float64
Load5 float64
Load15 float64
MemUsed int64
MemAvail int64
Shards []ShardLoad
}
// ShardLoad is one engine process group's use.
type ShardLoad struct {
ID string
CPUCores float64
RSSBytes int64
}
// userHZ is the unit /proc counts CPU time in. Fixed at 100 by the kernel's
// ABI on every architecture a node runs, whatever the internal tick rate.
const userHZ = 100
// LoadSampler measures load as the difference between two readings of /proc.
//
// CPU time is a counter, so a percentage needs two samples and the time
// between them. The sampler keeps the previous one; the first call has
// nothing to compare with and reports no load at all, rather than a zero that
// would read as an idle machine.
//
// Linux only, like every node. Elsewhere the files are missing and it reports
// nothing, as an older agent does.
type LoadSampler struct {
root string
now func() time.Time
mu sync.Mutex
prevAt time.Time
prevCPU cpuTimes
prevPG map[int]groupUse // process group -> what it has used
}
// groupUse is a process group's CPU time so far and its resident memory now.
type groupUse struct {
jiffies uint64
rssPages int64
}
type cpuTimes struct{ total, idle uint64 }
// NewLoadSampler reads the real /proc.
func NewLoadSampler() *LoadSampler { return newLoadSampler("/proc", time.Now) }
func newLoadSampler(root string, now func() time.Time) *LoadSampler {
return &LoadSampler{root: root, now: now}
}
// Sample reads the machine and the process groups led by the given shards'
// PIDs, keyed by shard ID. False when there is nothing to report yet.
func (s *LoadSampler) Sample(shards map[string]int) (Load, bool) {
cpu, ok := s.cpu()
if !ok {
return Load{}, false
}
groups := s.groups()
at := s.now()
s.mu.Lock()
prevAt, prevCPU, prevPG := s.prevAt, s.prevCPU, s.prevPG
s.prevAt, s.prevCPU, s.prevPG = at, cpu, groups
s.mu.Unlock()
if prevAt.IsZero() || cpu.total <= prevCPU.total {
return Load{}, false
}
l := Load{CPUCount: s.cpuCount()}
dTotal := float64(cpu.total - prevCPU.total)
dIdle := float64(cpu.idle - prevCPU.idle)
l.CPUPercent = clamp((dTotal-dIdle)/dTotal*100, 0, 100)
l.Load1, l.Load5, l.Load15 = s.loadavg()
total, avail := s.meminfo()
if total > 0 {
l.MemUsed, l.MemAvail = total-avail, avail
}
secs := at.Sub(prevAt).Seconds()
for id, pid := range shards {
if pid <= 0 {
continue
}
now := groups[pid]
sl := ShardLoad{ID: id, RSSBytes: now.rssPages * int64(os.Getpagesize())}
if before, ok := prevPG[pid]; ok && secs > 0 && now.jiffies >= before.jiffies {
sl.CPUCores = float64(now.jiffies-before.jiffies) / userHZ / secs
}
l.Shards = append(l.Shards, sl)
}
return l, true
}
func clamp(v, lo, hi float64) float64 { return max(lo, min(v, hi)) }
// cpu reads the aggregate line of /proc/stat. Idle includes iowait: a core
// waiting on the disk is not doing work.
func (s *LoadSampler) cpu() (cpuTimes, bool) {
b, err := os.ReadFile(filepath.Join(s.root, "stat"))
if err != nil {
return cpuTimes{}, false
}
line, _, _ := bytes.Cut(b, []byte("\n"))
f := strings.Fields(string(line))
if len(f) < 5 || f[0] != "cpu" {
return cpuTimes{}, false
}
var t cpuTimes
for i, v := range f[1:] {
// guest and guest_nice are already counted in user and nice.
if i >= 8 {
break
}
n, _ := strconv.ParseUint(v, 10, 64)
t.total += n
if i == 3 || i == 4 {
t.idle += n
}
}
return t, true
}
func (s *LoadSampler) cpuCount() int {
b, err := os.ReadFile(filepath.Join(s.root, "stat"))
if err != nil {
return 0
}
n := 0
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(line, "cpu") && len(line) > 3 && line[3] >= '0' && line[3] <= '9' {
n++
}
}
return n
}
func (s *LoadSampler) loadavg() (l1, l5, l15 float64) {
b, err := os.ReadFile(filepath.Join(s.root, "loadavg"))
if err != nil {
return 0, 0, 0
}
f := strings.Fields(string(b))
if len(f) < 3 {
return 0, 0, 0
}
l1, _ = strconv.ParseFloat(f[0], 64)
l5, _ = strconv.ParseFloat(f[1], 64)
l15, _ = strconv.ParseFloat(f[2], 64)
return l1, l5, l15
}
// meminfo returns MemTotal and MemAvailable in bytes.
func (s *LoadSampler) meminfo() (total, avail int64) {
f, err := os.Open(filepath.Join(s.root, "meminfo"))
if err != nil {
return 0, 0
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
k, v, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
fields := strings.Fields(v)
if len(fields) == 0 {
continue
}
kb, _ := strconv.ParseInt(fields[0], 10, 64)
switch k {
case "MemTotal":
total = kb * 1024
case "MemAvailable":
avail = kb * 1024
}
}
return total, avail
}
// procStat is what one /proc/<pid>/stat says that load needs.
type procStat struct {
pgrp int
jiffies uint64
rssPages int64
}
// readProcStat parses /proc/<pid>/stat. The command name is in parentheses
// and may itself hold spaces and parentheses, so fields are counted from the
// last closing one.
func readProcStat(path string) (procStat, bool) {
b, err := os.ReadFile(path)
if err != nil {
return procStat{}, false
}
i := bytes.LastIndexByte(b, ')')
if i < 0 {
return procStat{}, false
}
f := strings.Fields(string(b[i+1:]))
// After the name: state(0) ppid(1) pgrp(2) ... utime(11) stime(12) ... rss(21)
if len(f) < 22 {
return procStat{}, false
}
var st procStat
st.pgrp, _ = strconv.Atoi(f[2])
ut, _ := strconv.ParseUint(f[11], 10, 64)
stt, _ := strconv.ParseUint(f[12], 10, 64)
st.jiffies = ut + stt
st.rssPages, _ = strconv.ParseInt(f[21], 10, 64)
return st, true
}
// groups totals CPU time and memory per process group, in one pass over
// /proc. An engine is started as the leader of its own group, so its workers
// — vLLM starts several — are counted with it.
func (s *LoadSampler) groups() map[int]groupUse {
out := map[int]groupUse{}
s.eachProc(func(st procStat) {
g := out[st.pgrp]
g.jiffies += st.jiffies
g.rssPages += st.rssPages
out[st.pgrp] = g
})
return out
}
func (s *LoadSampler) eachProc(fn func(procStat)) {
entries, err := os.ReadDir(s.root)
if err != nil {
return
}
for _, e := range entries {
if _, err := strconv.Atoi(e.Name()); err != nil {
continue
}
if st, ok := readProcStat(filepath.Join(s.root, e.Name(), "stat")); ok {
fn(st)
}
}
}