Files
Ilker ManapandClaude Opus 5.5 0d3eb82b9b Facility-aware placement: racks, PDUs, holds and power caps
Nodes say where they are with llmgw.io/site, room, row, rack and pdu labels,
from agent.yaml or `llmgw ctl label` (kept in the store, over the agent's).
Placement spreads a model's replicas across racks and PDUs, keeps a
multi-node replica within one domain when its profile says keep_within,
places nothing in a domain held with `llmgw ctl facility hold` (optionally
draining it), and refuses a shard that would take a domain past its power
cap from facility.budgets, counting declared, derived and measured draw.
Explain-placement names the domain, and keeps the rejections of a placed
replica; `ctl nodes` gains LOCATION and --domain; `ctl facility` lists the
domains; the console's Nodes view shows and sets location and holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:17:49 +02:00

177 lines
5.1 KiB
Go

package inventory
import (
"maps"
"slices"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
)
// Location is where the node is, from its labels.
func (n Node) Location() facility.Location { return facility.LocationOf(n.Labels) }
// Domains are the failure domains the node is in, largest first.
func (n Node) Domains() []facility.Domain { return n.Location().Domains() }
// mergeLabels lays the operator's labels over the agent's. A copy, so
// neither is written through.
func mergeLabels(agent, operator map[string]string) map[string]string {
if len(operator) == 0 {
return maps.Clone(agent)
}
out := make(map[string]string, len(agent)+len(operator))
maps.Copy(out, agent)
maps.Copy(out, operator)
return out
}
// SetFacility puts the facility's holds and power budgets in force for
// every placement from now on.
func (s *Store) SetFacility(p facility.Policy) {
s.mu.Lock()
defer s.mu.Unlock()
s.facility = p.Clone()
}
// Facility is the policy in force; a copy, so a caller may change it.
func (s *Store) Facility() facility.Policy {
s.mu.RLock()
defer s.mu.RUnlock()
return s.facility.Clone()
}
// SetOperatorLabels replaces the labels set through the control plane for a
// node, which may not have registered yet: they are laid over its agent's
// when it does.
func (s *Store) SetOperatorLabels(id string, labels map[string]string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.operatorLabels == nil {
s.operatorLabels = map[string]map[string]string{}
}
if len(labels) == 0 {
delete(s.operatorLabels, id)
} else {
s.operatorLabels[id] = maps.Clone(labels)
}
if n, ok := s.nodes[id]; ok {
n.Labels = mergeLabels(n.AgentLabels, s.operatorLabels[id])
}
}
// OperatorLabels are the labels set through the control plane for a node.
func (s *Store) OperatorLabels(id string) map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
return maps.Clone(s.operatorLabels[id])
}
// PowerUse is one domain's draw as placement counts it.
type PowerUse struct {
Domain facility.Domain
// Nodes are the domain's nodes, sorted.
Nodes []string
// Reserved is what the replicas placed in the domain are estimated to
// draw, with each node's own draw and the domain's other equipment.
Reserved float64
// Measured is what the nodes' sensors read, with the same additions,
// for the nodes that read anything; the others count what is reserved.
Measured float64
// Used is what placement counts against the cap: per node, the larger
// of reserved and measured, so an estimate that was too low is
// corrected by the room it stops offering rather than by a breaker.
Used float64
// Limit is the cap less the margin; zero is no cap.
Limit float64
Budget facility.Budget
}
// PowerUse is every domain any node is in, or that has a budget, by domain.
func (s *Store) PowerUse(now time.Time) map[facility.Domain]PowerUse {
s.mu.RLock()
defer s.mu.RUnlock()
pol := s.facility
out := map[facility.Domain]PowerUse{}
for d, b := range pol.Budgets {
out[d] = PowerUse{Domain: d, Budget: b, Limit: pol.Limit(d),
Reserved: b.OtherWatts, Measured: b.OtherWatts, Used: b.OtherWatts}
}
ids := slices.Sorted(maps.Keys(s.nodes))
for _, id := range ids {
n := s.nodes[id]
reserved := 0.0
for _, a := range s.allocs[id] {
reserved += AllocationWatts(*n, a, pol)
}
measured, known := MeasuredWatts(*n, now, pol.Fresh())
used := reserved
if known && measured > used {
used = measured
}
if !known {
measured = reserved
}
for _, d := range n.Domains() {
u, ok := out[d]
if !ok {
u = PowerUse{Domain: d}
}
u.Nodes = append(u.Nodes, id)
u.Reserved += pol.NodeWatts + reserved
u.Measured += pol.NodeWatts + measured
u.Used += pol.NodeWatts + used
out[d] = u
}
}
return out
}
// AllocationWatts is what one shard is estimated to draw: what its profile
// declared, or else each of its cards at the power limit the card reports
// (the policy's figure for a card that reports none) and each of its cores
// at the policy's figure.
func AllocationWatts(n Node, a Allocation, pol facility.Policy) float64 {
if a.PowerW > 0 {
return a.PowerW
}
w := float64(a.CPUCores) * pol.CPUCoreWatts
for _, i := range a.GPUIndices {
w += GPUWatts(n, i, pol)
}
return w
}
// GPUWatts is one card's estimated draw: the power limit it reports, else
// the policy's figure for a card.
func GPUWatts(n Node, index int, pol facility.Policy) float64 {
if n.Load != nil {
for _, g := range n.Load.GPUs {
if g.Index == index && g.PowerLimitW > 0 {
return g.PowerLimitW
}
}
}
return pol.GPUWatts
}
// MeasuredWatts is what the node's sensors read at its last heartbeat: its
// cards and its CPU packages. Known is false when the reading is older than
// fresh, or the node has no power sensor at all, which a VM never has.
func MeasuredWatts(n Node, now time.Time, fresh time.Duration) (w float64, known bool) {
if n.Load == nil || now.Sub(n.Load.At) > fresh {
return 0, false
}
for _, g := range n.Load.GPUs {
if g.PowerW > 0 {
w += g.PowerW
known = true
}
}
if n.Load.CPUPowerW > 0 {
w += n.Load.CPUPowerW
known = true
}
return w, known
}