The release binary is built without cgo, so without NVML, and NVML was the only source of NVLink peers: on every NVLink machine the portable agent reported none, and tensor-parallel groups were chosen by GPU index. Discovery now reads `nvidia-smi topo -m` after either path, for the NVLink peers (kept from NVML where it gave them), each card's NUMA node, and how far each network adapter is from it - PIX, PXB, PHB, NODE or SYS - which travels to the control plane as nic_paths for placement to use. Both the NICn-with-legend layout of newer drivers and the adapter-named columns of older ones are read. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
127 lines
3.9 KiB
Go
127 lines
3.9 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// smiQueryFields are the columns requested from nvidia-smi, in order.
|
|
var smiQueryFields = []string{
|
|
"index",
|
|
"uuid",
|
|
"name",
|
|
"memory.total",
|
|
"memory.used",
|
|
"compute_cap",
|
|
"pci.bus_id",
|
|
"driver_version",
|
|
"temperature.gpu",
|
|
"ecc.errors.uncorrected.volatile.total",
|
|
// Last, and optional. An nvidia-smi too old to know this field returns a
|
|
// shorter row, and every column before it still lines up — which is why it
|
|
// goes on the end rather than anywhere else, and why the parser requires
|
|
// only the mandatory prefix.
|
|
"utilization.gpu",
|
|
}
|
|
|
|
// mandatorySMIFields is how many columns a row must have to be usable.
|
|
//
|
|
// Everything before utilization.gpu. A driver that does not know the last field
|
|
// gives a short row, and rejecting those would make an llmgw upgrade silently
|
|
// stop discovering GPUs on an older node — a far worse outcome than not knowing
|
|
// how busy a card is.
|
|
const mandatorySMIFields = 10
|
|
|
|
// DiscoverGPUsSMI enumerates GPUs by shelling out to nvidia-smi.
|
|
//
|
|
// This is the cgo-free path (plan §3.2): the NVML bindings need cgo, which
|
|
// would force the whole binary — server included — to be built with a C
|
|
// toolchain. The query gives everything except topology, which Discover
|
|
// reads from `nvidia-smi topo -m` afterwards.
|
|
func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
|
|
if binary == "" {
|
|
// Not configured and not installed is a node without an NVIDIA card,
|
|
// not a failed discovery. Reported as an error it also cut discovery
|
|
// short before InfiniBand was looked at, on every CPU node that had
|
|
// not been told by hand to skip GPUs.
|
|
if _, err := exec.LookPath("nvidia-smi"); err != nil {
|
|
return nil, nil
|
|
}
|
|
binary = "nvidia-smi"
|
|
}
|
|
out, err := exec.CommandContext(ctx, binary,
|
|
"--query-gpu="+strings.Join(smiQueryFields, ","),
|
|
"--format=csv,noheader,nounits",
|
|
).Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("running %s: %w", binary, err)
|
|
}
|
|
return parseSMI(string(out))
|
|
}
|
|
|
|
func parseSMI(out string) ([]GPU, error) {
|
|
r := csv.NewReader(strings.NewReader(out))
|
|
r.TrimLeadingSpace = true
|
|
r.FieldsPerRecord = -1 // tolerate driver versions with extra columns
|
|
|
|
rows, err := r.ReadAll()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing nvidia-smi output: %w", err)
|
|
}
|
|
|
|
gpus := make([]GPU, 0, len(rows))
|
|
for _, row := range rows {
|
|
if len(row) < mandatorySMIFields {
|
|
continue
|
|
}
|
|
idx, err := strconv.Atoi(strings.TrimSpace(row[0]))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
g := GPU{
|
|
Index: idx,
|
|
UUID: strings.TrimSpace(row[1]),
|
|
Name: strings.TrimSpace(row[2]),
|
|
ComputeCapability: strings.TrimSpace(row[5]),
|
|
PCIBusID: strings.TrimSpace(row[6]),
|
|
DriverVersion: strings.TrimSpace(row[7]),
|
|
NUMANode: -1,
|
|
}
|
|
g.MemoryTotalMB, _ = parseCSVInt(row[3])
|
|
g.MemoryUsedMB, _ = parseCSVInt(row[4])
|
|
g.Health.TemperatureC, _ = parseCSVInt(row[8])
|
|
g.Health.ECCErrors, _ = parseCSVInt(row[9])
|
|
if len(row) > 10 {
|
|
g.UtilizationPercent, _ = parseCSVInt(row[10])
|
|
}
|
|
|
|
assessHealth(&g)
|
|
gpus = append(gpus, g)
|
|
}
|
|
return gpus, nil
|
|
}
|
|
|
|
// residentMemoryToleranceMB is how much allocated memory is written off as
|
|
// driver overhead rather than a foreign process.
|
|
const residentMemoryToleranceMB = 512
|
|
|
|
// assessHealth flags conditions that should keep a GPU out of placement.
|
|
func assessHealth(g *GPU) {
|
|
if g.Health.ECCErrors > 0 {
|
|
g.Health.Problems = append(g.Health.Problems,
|
|
fmt.Sprintf("%d uncorrected ECC errors", g.Health.ECCErrors))
|
|
}
|
|
// Memory already in use means something else is on the card. Starting an
|
|
// engine there would either fail to allocate or quietly evict the other
|
|
// process's throughput.
|
|
if g.MemoryUsedMB > residentMemoryToleranceMB {
|
|
g.Health.Problems = append(g.Health.Problems,
|
|
fmt.Sprintf("%d MiB already allocated by another process", g.MemoryUsedMB))
|
|
}
|
|
}
|