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>
209 lines
6.3 KiB
Go
209 lines
6.3 KiB
Go
package modelplan
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
)
|
||
|
||
// Hardware is what one kind of node offers, as the fleet reports it.
|
||
type Hardware struct {
|
||
// Class is the GPU class, e.g. "h100-80g". Empty means a CPU-only node.
|
||
Class string
|
||
// GPUMemGB is one card's memory.
|
||
GPUMemGB int
|
||
// GPUsPerNode is how many cards a node of this kind has free.
|
||
GPUsPerNode int
|
||
// Nodes is how many such nodes are available.
|
||
Nodes int
|
||
// Fabric is what they are attached to, empty when they are not. A replica
|
||
// may never span two fabrics, so nodes on different ones are separate
|
||
// pools however identical their cards.
|
||
Fabric string
|
||
// HostMemGB is the machine's RAM, which is what a CPU profile needs.
|
||
HostMemGB int
|
||
}
|
||
|
||
// Fit is one way a model could run.
|
||
type Fit struct {
|
||
Precision Precision
|
||
Class string
|
||
TP int
|
||
Nodes int
|
||
GPUsPerNode int
|
||
Fabric string
|
||
// Context is the token pool this fit was checked against.
|
||
Context int
|
||
// PerGPUGB is what each card must hold, and Headroom what is left of it.
|
||
PerGPUGB float64
|
||
Headroom float64
|
||
// CPUOnly marks a fit that needs no GPU at all.
|
||
CPUOnly bool
|
||
}
|
||
|
||
// usableFraction is how much of a card a plan may assume it can have.
|
||
//
|
||
// Not all of it. A framework's allocator fragments, the driver reserves some,
|
||
// and a card planned to ninety-nine percent full is a card that fails to load
|
||
// on a bad day rather than a good one. vLLM's own default GPU utilisation is
|
||
// 0.90 and this matches it deliberately: a plan that disagrees with the engine
|
||
// it is planning for is worse than no plan.
|
||
const usableFraction = 0.90
|
||
|
||
// Fits returns every way this model could run on the given hardware, best
|
||
// first — fewest GPUs, then fewest nodes, then highest precision.
|
||
//
|
||
// Every candidate, not one answer, because the choice between them is not the
|
||
// planner's to make: a team with spare cards wants the full-precision fit, and
|
||
// a team with one card free wants to know a quantised one exists. Returning
|
||
// only the "best" would hide the option someone actually wants.
|
||
func Fits(m Model, hw []Hardware, context int, precisions []Precision) []Fit {
|
||
if context <= 0 {
|
||
context = m.MaxContext
|
||
}
|
||
if len(precisions) == 0 {
|
||
precisions = []Precision{BF16, FP8, Q8GGUF, Q4GGUF}
|
||
}
|
||
|
||
var out []Fit
|
||
for _, p := range precisions {
|
||
d := m.Needs(p, context)
|
||
for _, h := range hw {
|
||
out = append(out, fitsOn(m, h, d, p, context)...)
|
||
}
|
||
}
|
||
|
||
out = dedupe(out)
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
a, b := out[i], out[j]
|
||
if ga, gb := a.TP, b.TP; ga != gb {
|
||
return ga < gb
|
||
}
|
||
if a.Nodes != b.Nodes {
|
||
return a.Nodes < b.Nodes
|
||
}
|
||
// Higher precision first among equals: the same hardware serving a
|
||
// better model is strictly the better answer.
|
||
return a.Precision.BytesPerParam > b.Precision.BytesPerParam
|
||
})
|
||
return out
|
||
}
|
||
|
||
// dedupe drops fits that differ only by a fabric that does not matter.
|
||
//
|
||
// A cluster with two fabrics of identical cards produced every single-node fit
|
||
// twice, once per fabric, which is pure noise: a replica on one node does not
|
||
// span anything, so which fabric its node is attached to changes nothing about
|
||
// whether the model fits. Only a multi-node fit is fabric-specific, because
|
||
// that is the case where the fabric is the constraint.
|
||
//
|
||
// Found by running it against a real fleet: a 32B produced twenty-six rows of
|
||
// which thirteen were duplicates.
|
||
func dedupe(in []Fit) []Fit {
|
||
type key struct {
|
||
precision string
|
||
class string
|
||
tp, nodes int
|
||
fabric string
|
||
}
|
||
seen := map[key]struct{}{}
|
||
out := in[:0]
|
||
for _, f := range in {
|
||
k := key{f.Precision.Name, f.Class, f.TP, f.Nodes, ""}
|
||
if f.Nodes > 1 {
|
||
k.fabric = f.Fabric
|
||
}
|
||
if _, dup := seen[k]; dup {
|
||
continue
|
||
}
|
||
seen[k] = struct{}{}
|
||
out = append(out, f)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func fitsOn(m Model, h Hardware, d Demand, p Precision, context int) []Fit {
|
||
if h.Class == "" {
|
||
return cpuFit(h, d, p, context)
|
||
}
|
||
|
||
var out []Fit
|
||
budget := float64(h.GPUMemGB) * usableFraction
|
||
|
||
// Powers of two only. Tensor parallelism divides attention heads across
|
||
// ranks, and an engine that cannot divide them evenly refuses the profile —
|
||
// so offering tp=3 would produce a plan that fails at launch.
|
||
//
|
||
// One wide on MIG instances, however many there are: a process opens
|
||
// only one, and they have no NVLink between them.
|
||
widest := h.GPUsPerNode * h.Nodes
|
||
if isMIG(h.Class) {
|
||
widest = min(widest, 1)
|
||
}
|
||
for width := 1; width <= widest; width *= 2 {
|
||
nodes := 1
|
||
perNode := width
|
||
if width > h.GPUsPerNode {
|
||
perNode = h.GPUsPerNode
|
||
nodes = width / h.GPUsPerNode
|
||
}
|
||
if nodes > h.Nodes {
|
||
break
|
||
}
|
||
// A replica spanning nodes needs a fabric. Without one every node is
|
||
// its own island, and a multi-node replica would hang on its first
|
||
// all-reduce rather than fail to start.
|
||
if nodes > 1 && h.Fabric == "" {
|
||
continue
|
||
}
|
||
// The KV heads have to divide too, and they are far fewer than the
|
||
// attention heads under grouped-query attention: a model with 8 KV
|
||
// heads cannot be split sixteen ways.
|
||
if m.KVHeads > 0 && width > m.KVHeads {
|
||
break
|
||
}
|
||
|
||
per := GB(d.PerGPU(width))
|
||
if per > budget {
|
||
continue
|
||
}
|
||
out = append(out, Fit{
|
||
Precision: p, Class: h.Class, TP: width, Nodes: nodes,
|
||
GPUsPerNode: perNode, Fabric: h.Fabric, Context: context,
|
||
PerGPUGB: per, Headroom: budget - per,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// cpuFit checks a node with no cards, where the whole working set lives in RAM.
|
||
func cpuFit(h Hardware, d Demand, p Precision, context int) []Fit {
|
||
if h.HostMemGB <= 0 {
|
||
return nil
|
||
}
|
||
// Less of the machine than of a card: the operating system, the page cache
|
||
// and everything else on the node want their share, and a CPU node is
|
||
// rarely dedicated the way a GPU node is.
|
||
budget := float64(h.HostMemGB) * 0.75
|
||
need := GB(d.Total())
|
||
if need > budget {
|
||
return nil
|
||
}
|
||
return []Fit{{
|
||
Precision: p, TP: 1, Nodes: 1, Context: context,
|
||
PerGPUGB: need, Headroom: budget - need, CPUOnly: true,
|
||
}}
|
||
}
|
||
|
||
// Describe renders a fit the way someone would say it out loud.
|
||
func (f Fit) Describe() string {
|
||
if f.CPUOnly {
|
||
return fmt.Sprintf("CPU, %s, %.1f GB of RAM", f.Precision.Name, f.PerGPUGB)
|
||
}
|
||
where := fmt.Sprintf("%d×%s", f.TP, f.Class)
|
||
if f.Nodes > 1 {
|
||
where = fmt.Sprintf("%d nodes × %d×%s on %s", f.Nodes, f.GPUsPerNode, f.Class, f.Fabric)
|
||
}
|
||
return fmt.Sprintf("%s, %s, %.1f GB a card (%.1f free)",
|
||
where, f.Precision.Name, f.PerGPUGB, f.Headroom)
|
||
}
|