Discovery reports a card in MIG mode as its instances instead of the whole card, on both paths: nvidia-smi (-L names them, -q -x gives their memory, ids and processes) and NVML (GetMigMode, GetMaxMigDeviceCount, GetMigDeviceHandleByIndex, behind an interface so it is tested with a fake). Each instance has its MIG UUID, its own memory, and a class from the card's and the profile: a100-80g-mig-3g.40gb. Placement numbers instances 1000 + 100 x card + instance and every view shows them as card/migN. A replica on MIG runs on exactly one instance: a process opens one MIG device and instances have no NVLink between them, so tp, pp, gpus or nodes above one on a MIG class are refused in catalog validation and again in placement, and the planner offers one instance or none. The agent gives the engine CUDA_VISIBLE_DEVICES=MIG-<uuid> (no Vulkan device variable on MIG). Memory and processes are per instance; heat, power and wear the card's; utilisation is left out rather than reported as zero. A card in MIG mode with nothing carved stays out of placement with the reason, and llmgw doctor reports each card's mode and instances and warns of the states in which nothing can run. ctl nodes, ctl status, the console and the plan count devices by class. docs/install.md has a MIG section. Written against fakes of nvidia-smi and NVML output; not yet run on MIG hardware. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
193 lines
5.6 KiB
Go
193 lines
5.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
|
)
|
|
|
|
func newAgentCmd() *cobra.Command {
|
|
var configPath string
|
|
|
|
var (
|
|
server string
|
|
nodeID string
|
|
mgmtAddr string
|
|
)
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "agent",
|
|
Short: "Node agent: discovery and engine supervision",
|
|
Long: "Runs on every GPU node. It discovers the hardware, connects to the control\n" +
|
|
"plane, and starts, supervises and stops inference engines on demand.\n\n" +
|
|
"The agent dials out and keeps one stream open. Engines keep serving if the\n" +
|
|
"agent restarts or the control plane goes away: the router talks to them\n" +
|
|
"directly, and orphan adoption picks them up again.",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
log := logger()
|
|
|
|
cfg, err := config.LoadAgent(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if server != "" {
|
|
cfg.Server = server
|
|
}
|
|
if nodeID != "" {
|
|
cfg.NodeID = nodeID
|
|
}
|
|
if mgmtAddr != "" {
|
|
cfg.MgmtAddr = mgmtAddr
|
|
}
|
|
if cfg.NodeID == "" {
|
|
host, err := os.Hostname()
|
|
if err != nil {
|
|
return fmt.Errorf("node_id is unset and the hostname is unavailable: %w", err)
|
|
}
|
|
cfg.NodeID = host
|
|
}
|
|
if cfg.Hostname == "" {
|
|
cfg.Hostname, _ = os.Hostname()
|
|
}
|
|
cfg.Version = version.Version
|
|
|
|
httpClient, err := agent.Client(cfg.TLS, 30*time.Second)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a, err := agent.New(cfg, httpClient, log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
log.Info("agent starting",
|
|
"node", cfg.NodeID, "server", cfg.Server,
|
|
"fabrics", len(cfg.Fabrics), "nvml", discovery.NVMLSupported())
|
|
|
|
err = a.Run(ctx)
|
|
if errors.Is(err, context.Canceled) {
|
|
return nil
|
|
}
|
|
return err
|
|
},
|
|
}
|
|
cmd.Flags().StringVar(&configPath, "config", "", "configuration file")
|
|
cmd.Flags().StringVar(&server, "server", "", "control plane address (overrides the config)")
|
|
cmd.Flags().StringVar(&nodeID, "node-id", "", "node id (defaults to the hostname)")
|
|
cmd.Flags().StringVar(&mgmtAddr, "mgmt-addr", "", "address engines are reachable on")
|
|
cmd.AddCommand(newInventoryCmd())
|
|
return cmd
|
|
}
|
|
|
|
func newInventoryCmd() *cobra.Command {
|
|
var (
|
|
asJSON bool
|
|
smiBin string
|
|
ibRoot string
|
|
skipGPU bool
|
|
useNVML bool
|
|
)
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "inventory",
|
|
Short: "Show this node's GPUs and InfiniBand adapters",
|
|
Long: "Reports what the scheduler would see on this node: GPU classes, health,\n" +
|
|
"and the fabric each adapter is attached to.\n\n" +
|
|
"Run it before adding a node to the cluster — it is the fastest way to find\n" +
|
|
"a card another process is already using, or a port with no subnet manager.",
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
inv, err := discovery.Discover(cmd.Context(), discovery.Options{
|
|
NvidiaSMI: smiBin,
|
|
IBRoot: ibRoot,
|
|
PreferNVML: useNVML,
|
|
SkipGPUs: skipGPU,
|
|
}, logger())
|
|
if err != nil {
|
|
// Not fatal: a node whose GPUs cannot be read still has IB
|
|
// worth showing, and the error is itself the useful output.
|
|
fmt.Fprintf(os.Stderr, "%v\n", err)
|
|
inv.Source = "unavailable"
|
|
}
|
|
|
|
if asJSON {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(inv)
|
|
}
|
|
printInventory(inv)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
f := cmd.Flags()
|
|
f.BoolVar(&asJSON, "json", false, "emit JSON")
|
|
f.StringVar(&smiBin, "nvidia-smi", "", "path to nvidia-smi")
|
|
f.StringVar(&ibRoot, "ib-root", discovery.SysfsRoot, "sysfs InfiniBand directory")
|
|
f.BoolVar(&skipGPU, "no-gpu", false, "skip GPU discovery")
|
|
f.BoolVar(&useNVML, "nvml", true, "prefer the NVML backend when this build has one (it is the only source of NVLink topology)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func printInventory(inv discovery.Inventory) {
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
|
|
fmt.Fprintf(w, "GPUs (%d, via %s)\n", len(inv.GPUs), inv.Source)
|
|
if len(inv.GPUs) == 0 {
|
|
fmt.Fprintln(w, " none")
|
|
} else {
|
|
fmt.Fprintln(w, " IDX\tCLASS\tNAME\tMEM\tCC\tHEALTH")
|
|
for _, g := range inv.GPUs {
|
|
health := "ok"
|
|
if !g.Health.OK() {
|
|
health = g.Health.Problems[0]
|
|
}
|
|
fmt.Fprintf(w, " %s\t%s\t%s\t%d MiB\t%s\t%s\n",
|
|
discovery.DeviceLabel(g.Index), g.Class(), g.Name, g.MemoryTotalMB, g.ComputeCapability, health)
|
|
}
|
|
fmt.Fprintf(w, " classes: %v\n", inv.Classes())
|
|
for _, g := range inv.GPUs {
|
|
if len(g.NVLinkPeers) > 0 {
|
|
fmt.Fprintf(w, " GPU %s NVLink peers: %v\n", discovery.DeviceLabel(g.Index), g.NVLinkPeers)
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Fprintf(w, "\nInfiniBand adapters (%d)\n", len(inv.HCAs))
|
|
if len(inv.HCAs) == 0 {
|
|
// Worth stating plainly: such a node is its own single-node island and
|
|
// can never host a multi-node replica.
|
|
fmt.Fprintln(w, " none — this node cannot host multi-node replicas")
|
|
} else {
|
|
fmt.Fprintln(w, " HCA\tPORT\tSTATE\tRATE\tLINK\tGID PREFIX\tNUMA")
|
|
for _, h := range inv.HCAs {
|
|
for _, p := range h.Ports {
|
|
fmt.Fprintf(w, " %s\t%d\t%s\t%d Gb/s\t%s\t%s\t%d\n",
|
|
h.Name, p.Num, p.State, p.RateGbps, p.LinkLayer, p.GIDPrefix, h.NUMANode)
|
|
}
|
|
}
|
|
fmt.Fprintln(w, "\n Fabric identity is not derived from the GID prefix by default:")
|
|
fmt.Fprintln(w, " OpenSM's default is fe80:: on every fabric, so a shared prefix proves")
|
|
fmt.Fprintln(w, " nothing. Label fabrics explicitly in agent.yaml (plan §5.2).")
|
|
}
|
|
|
|
_ = w.Flush()
|
|
}
|