Everything here works without GPUs and is covered by tests. The control plane connection is not built yet, so llmgw agent itself is still a stub and the phase exit criterion is not met. - discovery: IB from /sys/class/infiniband, parsing the formats sysfs actually uses and telling native InfiniBand from RoCE; GPUs via nvidia-smi, the cgo-free path, with health flags for uncorrected ECC and memory already held by another process. A node without IB is not an error, it is a single-node island. - engines: vLLM and SGLang command and environment construction, kept free of process handling so it stays testable. The NCCL environment is the part the plan marks critical: NCCL_IB_HCA lists only the chosen fabric's adapters, because leaving it open lets NCCL pick an adapter on a fabric where the peer is unreachable, and the job then hangs instead of failing. - validation before launch: no mixed GPU types in a tensor-parallel group, the node/GPU/TP arithmetic has to add up, and fp8 is refused below compute capability 8.9 rather than silently falling back to a slower path. - supervisor: process groups, because engines fork workers that hold GPU memory and outlive a signal sent only to the leader; a bounded log tail, since engine output is the only diagnosis when loading fails; Starting separate from Loading, because a 70B model legitimately takes minutes; and orphan adoption through pid files fingerprinted with the command line, since pids get reused and adopting an unrelated process is worse than leaking the original. - readiness is two questions: /health says the process is up, /v1/models says the weights loaded. Routing on /health alone sends traffic into a loading model. - llmgw agent inventory reports what the scheduler would see on a node. One plan correction: GPU class sizes come from the driver in GiB, which does not always match the marketing name. An L40S is sold as 48GB and reports 44.99 GiB, so its class is l40s-45g, not l40s-48g. Consistency matters more than matching the box, because that number decides whether a model fits. Plan §2 and §5.1 updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
Go
134 lines
4.4 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"`
|
|
}
|
|
|
|
// 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
|
|
}
|