Files
llmgw/internal/agent/discovery/gpu.go
T
Ilker ManapandClaude Opus 5.5 721705b29d
CI / test (push) Successful in 9m17s
A card in the wrong PCIe slot is said so
At the user's asking: we know what a card's link could be, so a card
linked slower is reported. Each agent reads, apart from the GPU query so
that a driver not knowing a field costs nothing else, the fastest PCIe
generation card and slot allow together, the card's own, and the lanes in
use and the card's most (nvidia-smi; NVML in the nvml build). The current
generation is not compared: it drops while a card idles. A card on fewer
lanes, or a slot of an older generation, is a node warning with the GB/s
it carries against what it could — on the dashboard, the console and in
llmgw doctor, which points at lspci's LnkCap and LnkSta. MIG instances are
said of their card. GPU proto fields 23-26.

The PoC's Quadro RTX 6000 reads Gen3 of Gen3, x16 of x16, idle at Gen1:
nothing to say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:49:16 +01:00

190 lines
7.0 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"`
// UtilizationPercent is how busy the card was over the last sample period.
//
// The number people actually watch, and the one nothing collected: memory
// used says a model is loaded, and this says whether it is doing anything.
// A fleet of cards at ninety percent occupancy and two percent utilisation
// is a fleet holding models nobody is asking for, which is invisible
// without it.
UtilizationPercent int `json:"utilization_percent,omitempty"`
// 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"`
// NICPaths is how far each network adapter is from this GPU, by HCA name:
// PIX, PXB, PHB, NODE or SYS, as nvidia-smi topo reports it. What decides
// which adapter a GPU's share of a multi-node job should go through.
NICPaths map[string]string `json:"nic_paths,omitempty"`
// Fabric is the GPU's multi-node NVLink membership, nil where the
// driver reports none.
Fabric *GPUFabric `json:"fabric,omitempty"`
// Health carries anything that should keep the GPU out of placement.
Health GPUHealth `json:"health"`
// MIG is set when this is a MIG instance rather than a whole card: its
// Index is then MIGIndex's, its UUID the instance's (MIG-…), and its
// memory the instance's share. Utilisation stays unreported, because no
// driver measures it per instance.
MIG *MIGDevice `json:"mig,omitempty"`
// PCIe is how the card is linked to its host, zero when not read.
PCIe PCIeLink `json:"pcie,omitempty"`
}
// 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.
// A MIG instance is named from its card's memory and then its profile,
// so two instances are one class only when both the card and the slice
// are the same.
mem := g.MemoryTotalMB
if g.MIG != nil {
mem = g.MIG.ParentMemoryTotalMB
}
gb := int(math.Round(float64(mem) / 1024))
class := fmt.Sprintf("%s-%dg", model, gb)
if g.MIG != nil {
return migClass(class, g.MIG.Profile)
}
return class
}
// normalizeBusID makes bus ids comparable: NVML reports them uppercase from one
// call and lowercase from another, and sometimes with a shortened domain.
func normalizeBusID(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// "0000:1b:00.0" and "00000000:1B:00.0" are the same device.
if i := strings.Index(s, ":"); i > 0 {
s = strings.TrimLeft(s[:i], "0") + s[i:]
}
return strings.TrimPrefix(s, ":")
}
// 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"`
// Weights are the model files this node can see. Empty means it was not
// asked to look.
Weights []LocalWeights `json:"weights,omitempty"`
// Storage is the disk behind those weights, one entry per filesystem.
// Empty means the same thing it means for Weights: not asked.
Storage []Storage `json:"storage,omitempty"`
// IMEX is the service a multi-node NVLink domain needs, read where the
// node has GPUs.
IMEX IMEX `json:"imex"`
}
// 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
}