Files
llmgw/internal/agent/discovery/gpu.go
T
Ilker ManapandClaude Opus 5 3081c6f69b
CI / test (push) Successful in 1m23s
Place on nodes that can actually hold the model
Placement had no idea how much memory a node has or a model needs. A CPU
profile has no card to be free, so eligibleNodes accepted every ready node:
Qwen2.5-Coder 14B, whose working set is about 24 GB, would have been placed
just as happily on one of the 8 GB nodes, which reports itself perfectly
healthy right up until the OOM killer arrives.

node_selector was yesterday's stopgap and it is the operator restating by
hand something the system can know. Agents now report MemTotal, profiles
declare min_memory_gb, and placement filters on it, naming what was short —
"has 8 GB of memory, needs 24" is actionable where "no candidates were
considered" is not. The label selector stays, because "this node is for
coding models" is a real thing to want to say that memory does not express.

Two deliberate non-exclusions. A profile with no requirement fits anywhere,
which is every profile that exists today. And a node that reports no memory
is still usable: an agent older than the field says nothing, and letting a
control plane upgrade quietly empty half a fleet is a worse failure than the
one this prevents.

ctl nodes grows a MEM column showing "-" rather than 0 for a node that did
not say, because an old agent is not a small machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 23:12:44 +02:00

137 lines
4.6 KiB
Go

package discovery
import (
"encoding/json"
"fmt"
"math"
"regexp"
"slices"
"strconv"
"strings"
)
// GPU is one accelerator on this node.
type GPU struct {
Index int `json:"index"`
UUID string `json:"uuid"`
Name string `json:"name"`
// MemoryTotalMB is the total framebuffer size.
MemoryTotalMB int `json:"memory_total_mb"`
// MemoryUsedMB is what is already allocated. Non-zero on an idle node means
// something other than our engines is using the card (plan §5.1).
MemoryUsedMB int `json:"memory_used_mb"`
// ComputeCapability gates quantization choices: an FP8 profile must not
// land on a card below 8.9 (plan §6.2).
ComputeCapability string `json:"compute_capability"`
NUMANode int `json:"numa_node"`
PCIBusID string `json:"pci_bus_id,omitempty"`
DriverVersion string `json:"driver_version,omitempty"`
CUDAVersion string `json:"cuda_version,omitempty"`
// NVLinkPeers are the indices of GPUs reachable over NVLink. Within a node
// a TP group should stay inside one NVLink domain.
NVLinkPeers []int `json:"nvlink_peers,omitempty"`
// Health carries anything that should keep the GPU out of placement.
Health GPUHealth `json:"health"`
}
// GPUHealth records conditions that make a GPU unsafe to schedule onto.
type GPUHealth struct {
// ECCErrors is the count of uncorrected ECC errors.
ECCErrors int `json:"ecc_errors"`
// TemperatureC is the current die temperature.
TemperatureC int `json:"temperature_c"`
// Problems lists human-readable reasons to avoid this GPU.
Problems []string `json:"problems,omitempty"`
}
// OK reports whether the GPU is fit for placement.
func (h GPUHealth) OK() bool { return len(h.Problems) == 0 }
// nonAlnum collapses everything that is not a letter or digit.
var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
// Class derives the GPU class used for placement: same model, same memory.
//
// Two cards only belong in the same tensor-parallel group if they are the same
// class, so the derivation has to be stable across driver versions and naming
// quirks ("NVIDIA H100 80GB HBM3" and "H100-SXM5-80GB" are the same class).
func (g GPU) Class() string {
name := strings.ToLower(g.Name)
name = strings.TrimPrefix(name, "nvidia ")
name = strings.TrimPrefix(name, "tesla ")
// Keep the model token: the first word that contains a digit, which is how
// NVIDIA names every accelerator (h100, a100, l40s, rtx4090).
var model string
for _, tok := range nonAlnum.Split(name, -1) {
if tok == "" || !strings.ContainsFunc(tok, func(r rune) bool { return r >= '0' && r <= '9' }) {
continue
}
// Skip memory and form-factor tokens, which are not the model.
if strings.HasSuffix(tok, "gb") || tok == "80gb" || tok == "sxm4" || tok == "sxm5" || tok == "pcie" {
continue
}
model = tok
break
}
if model == "" {
model = nonAlnum.ReplaceAllString(name, "-")
}
// Round to the nearest GiB: vendors report 81559 MiB for an "80GB" card.
gb := int(math.Round(float64(g.MemoryTotalMB) / 1024))
return fmt.Sprintf("%s-%dg", model, gb)
}
// MarshalJSON adds the derived class, so a consumer of the JSON does not have
// to reimplement the derivation to know which GPUs are interchangeable.
func (g GPU) MarshalJSON() ([]byte, error) {
type alias GPU // prevents recursing back into this method
return json.Marshal(struct {
alias
Class string `json:"class"`
}{alias(g), g.Class()})
}
// Inventory is everything discovery found on this node.
type Inventory struct {
GPUs []GPU `json:"gpus"`
HCAs []HCA `json:"hcas"`
// Source names the backend that produced the GPU list, so a consumer can
// tell "no GPUs" from "discovery did not work".
Source string `json:"source"`
// MemoryTotalMB is the machine's RAM. Zero means unknown, which is what an
// agent older than this field reports, and is treated as no constraint.
MemoryTotalMB int `json:"memory_total_mb,omitempty"`
}
// Classes returns the distinct GPU classes present, sorted.
func (inv Inventory) Classes() []string {
seen := map[string]struct{}{}
for _, g := range inv.GPUs {
seen[g.Class()] = struct{}{}
}
out := make([]string, 0, len(seen))
for c := range seen {
out = append(out, c)
}
slices.Sort(out)
return out
}
// parseCSVInt reads an integer from nvidia-smi's CSV output, tolerating the
// "[N/A]" and "Not Supported" values it emits for unsupported queries.
func parseCSVInt(s string) (int, bool) {
s = strings.TrimSpace(s)
s = strings.TrimSuffix(s, " MiB")
s = strings.TrimSuffix(s, " W")
if s == "" || strings.HasPrefix(s, "[") || strings.EqualFold(s, "Not Supported") {
return 0, false
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return v, true
}