Files
llmgw/internal/modelplan/options.go
T
Ilker ManapandClaude Opus 5.5 0fd55a862b MIG: a card in MIG mode is placed as its instances, each a GPU of class <card>-mig-<profile>
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>
2026-10-05 00:01:14 +02:00

428 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package modelplan
import (
"fmt"
"regexp"
"sort"
"strings"
)
// Options: the ways the files a repository actually has could run on the
// cluster as it is — each file on one card, on several of a node's cards, on
// several nodes' cards over a fabric, or on a CPU node — with what each card
// would hold, and whether the cards are free now or only exist.
// Artifact is something a repository offers to serve: one GGUF file (or one
// split into parts), or its original safetensors weights.
type Artifact struct {
// Name is the file, or the set's name; for original weights, "original".
Name string `json:"name"`
Format string `json:"format"` // "gguf" or "safetensors"
Bytes int64 `json:"bytes"`
Shards int `json:"shards,omitempty"`
// Quant is the quantisation read from the name, like Q4_K_M; "bf16" for
// original weights.
Quant string `json:"quant,omitempty"`
}
// Shape is what sizes a model's KV cache, and constrains how it splits.
type Shape struct {
// KVBytesPerToken is one token of context, the whole model.
KVBytesPerToken int64
// KVHeads must divide by a tensor-parallel width; 0 is unknown.
KVHeads int
// Params is the parameter count, for bits per weight; 0 is unknown.
Params int64
}
// PoolNode is one ready node, as a plan sees it.
type PoolNode struct {
ID string
Class string // "" for a node with no GPU
Fabric string
GPUMemGB int
GPUs int
FreeGPUs int
// HostMemGB is its RAM, and HostFreeGB what is unused now.
HostMemGB int
HostFreeGB int
// Kinds are the node's devices by class, for a node with more than one:
// a card in MIG mode is one or several classes of instance, and a whole
// card beside it another. Empty means Class, GPUMemGB, GPUs and FreeGPUs
// say it all.
Kinds []PoolKind
}
// PoolKind is one class of a node's devices.
type PoolKind struct {
Class string
GPUMemGB int
GPUs int
FreeGPUs int
}
// kinds is the node's devices by class.
func (n PoolNode) kinds() []PoolKind {
if len(n.Kinds) > 0 {
return n.Kinds
}
return []PoolKind{{Class: n.Class, GPUMemGB: n.GPUMemGB, GPUs: n.GPUs, FreeGPUs: n.FreeGPUs}}
}
// isMIG reports whether a class names MIG instances: "a100-80g-mig-3g.40gb".
// A replica runs on exactly one, never a group of them, so a plan offers one
// instance and nothing wider. Read from the class label, as the card's memory
// is, which keeps this package free of the agent's.
func isMIG(class string) bool { return strings.Contains(class, "-mig-") }
// Option is one way to run one artifact.
type Option struct {
Artifact string `json:"artifact"`
Format string `json:"format"`
Engine string `json:"engine"`
CPU bool `json:"cpu,omitempty"`
Class string `json:"gpu_class,omitempty"`
Fabric string `json:"fabric,omitempty"`
// GPUsPerNode cards on each of Nodes nodes.
GPUsPerNode int `json:"gpus_per_node,omitempty"`
Nodes int `json:"nodes,omitempty"`
// NeedGB is what one card holds, or for CPU the whole replica in RAM;
// HaveGB what a card, or the node, offers for it.
NeedGB float64 `json:"need_gb"`
HaveGB float64 `json:"have_gb"`
// Hosts is how many nodes could run it at all, and FreeNow whether one
// (or enough, together) could right now.
Hosts int `json:"hosts"`
FreeNow bool `json:"free_now"`
// BitsPerWeight is the artifact's precision, 0 when unknown.
BitsPerWeight float64 `json:"bits_per_weight,omitempty"`
}
// GPUs is every card one replica takes.
func (o Option) GPUs() int { return o.GPUsPerNode * max(o.Nodes, 1) }
// Label says where it runs, in a few words.
func (o Option) Label() string {
if o.CPU {
return "CPU"
}
if o.Nodes > 1 {
return fmt.Sprintf("%d nodes × %d %s on %s", o.Nodes, o.GPUsPerNode, o.Class, o.Fabric)
}
return fmt.Sprintf("%d × %s", o.GPUsPerNode, o.Class)
}
// gpuShare is how much of a card a plan may count on, as in Fits.
const gpuShare = usableFraction
// hostShare is how much of a node's RAM one replica may be planned to use:
// the system and the agent keep the rest.
const hostShare = 0.85
// ggufOverhead is llama.cpp's own cost beyond weights and cache, as the GGUF
// header's MemoryBytes counts it: compute buffers and the server.
func ggufOverhead(weights int64) int64 { return 1<<30 + weights/20 }
// Options lists every way each artifact could run, best first.
//
// context is the token pool: llama.cpp's --ctx-size, or vLLM's
// --max-model-len for one sequence.
func Options(arts []Artifact, sh Shape, nodes []PoolNode, context int) []Option {
var out []Option
for _, a := range arts {
bpw := quantBits[strings.ToUpper(a.Quant)]
if sh.Params > 0 {
bpw = float64(a.Bytes) * 8 / float64(sh.Params)
}
kv := int64(context) * sh.KVBytesPerToken
switch a.Format {
case "gguf":
out = append(out, ggufOptions(a, kv, nodes, bpw)...)
case "safetensors":
out = append(out, vllmOptions(a, kv, sh, nodes, bpw)...)
}
}
Rank(out)
return out
}
func ggufOptions(a Artifact, kv int64, nodes []PoolNode, bpw float64) []Option {
var out []Option
// The CPU: the whole replica in a node's RAM.
total := a.Bytes + kv + ggufOverhead(a.Bytes)
cpu := Option{Artifact: a.Name, Format: a.Format, Engine: "llamacpp", CPU: true,
NeedGB: GB(total), BitsPerWeight: bpw}
for _, n := range nodes {
have := float64(n.HostMemGB) * hostShare
if n.HostMemGB <= 0 || GB(total) > have {
continue
}
cpu.Hosts++
cpu.HaveGB = max(cpu.HaveGB, have)
if float64(n.HostFreeGB) >= GB(total) {
cpu.FreeNow = true
}
}
if cpu.Hosts > 0 {
out = append(out, cpu)
}
// GPUs: one node's cards, the model split across them by layer.
for _, c := range classes(nodes) {
budget := float64(c.mem) * gpuShare
for width := 1; width <= c.maxGPUs; width++ {
per := (a.Bytes+kv)/int64(width) + ggufOverhead(a.Bytes/int64(width))
if GB(per) > budget {
continue
}
o := Option{Artifact: a.Name, Format: a.Format, Engine: "llamacpp", Class: c.class,
GPUsPerNode: width, Nodes: 1, NeedGB: GB(per), HaveGB: budget, BitsPerWeight: bpw}
for _, fab := range c.fabrics {
for _, n := range fab.nodes {
if n.GPUs >= width {
o.Hosts++
if n.FreeGPUs >= width {
o.FreeNow = true
}
}
}
}
out = append(out, o)
break // more cards than the fewest that hold it buys nothing here
}
}
return out
}
func vllmOptions(a Artifact, kv int64, sh Shape, nodes []PoolNode, bpw float64) []Option {
var out []Option
perCard := func(width int) int64 {
return (a.Bytes+kv)/int64(width) + overheadBytes(a.Bytes/int64(width))
}
// Tensor parallel within a node must divide the KV heads.
divides := func(w int) bool { return sh.KVHeads == 0 || (w <= sh.KVHeads && sh.KVHeads%w == 0) }
for _, c := range classes(nodes) {
budget := float64(c.mem) * gpuShare
opt := func(perNode, n int, fabric string) Option {
return Option{Artifact: a.Name, Format: a.Format, Engine: "vllm", Class: c.class, Fabric: fabric,
GPUsPerNode: perNode, Nodes: n, NeedGB: GB(perCard(perNode * n)), HaveGB: budget, BitsPerWeight: bpw}
}
// One node: the narrowest power of two that holds it.
single := false
for w := 1; w <= c.maxGPUs && divides(w); w *= 2 {
if GB(perCard(w)) > budget {
continue
}
o := opt(w, 1, "")
for _, fab := range c.fabrics {
for _, nd := range fab.nodes {
if nd.GPUs >= w {
o.Hosts++
o.FreeNow = o.FreeNow || nd.FreeGPUs >= w
}
}
}
out = append(out, o)
single = true
break
}
// A MIG instance does not join others, on its node or across a
// fabric: what does not fit one does not fit the class.
if single || isMIG(c.class) {
continue
}
// Several nodes, a node's full width on each, pipeline-parallel
// between them: only within one fabric.
w := 1
for w*2 <= c.maxGPUs {
w *= 2
}
if !divides(w) {
continue
}
for _, fab := range c.fabrics {
if fab.name == "" {
continue
}
for n := 2; n <= len(fab.nodes); n *= 2 {
if GB(perCard(w*n)) > budget {
continue
}
o := opt(w, n, fab.name)
free := 0
for _, nd := range fab.nodes {
if nd.GPUs >= w {
o.Hosts++
}
if nd.FreeGPUs >= w {
free++
}
}
if o.Hosts < n {
break
}
o.FreeNow = free >= n
out = append(out, o)
break
}
}
}
return out
}
type classPool struct {
class string
mem int
maxGPUs int
fabrics []fabricPool
}
type fabricPool struct {
name string
nodes []PoolNode
}
// classes groups GPU nodes by card, and each by fabric: a replica spans
// nodes only within one. A node with devices of several classes is in each
// class's pool, as the node it is for that class: its devices of that class
// and no others.
func classes(nodes []PoolNode) []classPool {
by := map[string]*classPool{}
var order []string
for _, node := range nodes {
for _, k := range node.kinds() {
if k.Class == "" || k.GPUs == 0 || k.GPUMemGB == 0 {
continue
}
n := node
n.Class, n.GPUMemGB, n.GPUs, n.FreeGPUs, n.Kinds = k.Class, k.GPUMemGB, k.GPUs, k.FreeGPUs, nil
if isMIG(n.Class) {
// One instance a replica, however many the node has.
n.GPUs, n.FreeGPUs = min(n.GPUs, 1), min(n.FreeGPUs, 1)
}
c := by[n.Class]
if c == nil {
c = &classPool{class: n.Class, mem: n.GPUMemGB}
by[n.Class] = c
order = append(order, n.Class)
}
c.maxGPUs = max(c.maxGPUs, n.GPUs)
i := -1
for j := range c.fabrics {
if c.fabrics[j].name == n.Fabric {
i = j
}
}
if i < 0 {
c.fabrics = append(c.fabrics, fabricPool{name: n.Fabric})
i = len(c.fabrics) - 1
}
c.fabrics[i].nodes = append(c.fabrics[i].nodes, n)
}
}
sort.Strings(order)
out := make([]classPool, 0, len(order))
for _, k := range order {
out = append(out, *by[k])
}
return out
}
// quantBits is roughly what each quantisation spends a weight, for when the
// parameter count is not known to work it out from the file's size.
var quantBits = map[string]float64{
"F32": 32, "F16": 16, "BF16": 16, "FP16": 16, "Q8_0": 8.5, "Q6_K": 6.6,
"Q5_K_M": 5.7, "Q5_K_S": 5.5, "Q5_0": 5.5, "Q5_1": 6, "Q4_K_M": 4.8, "Q4_K_S": 4.6, "Q4_0": 4.5, "Q4_1": 5,
"IQ4_XS": 4.3, "IQ4_NL": 4.5, "Q3_K_L": 4.3, "Q3_K_M": 3.9, "Q3_K_S": 3.5, "IQ3_M": 3.7, "IQ3_XS": 3.3,
"IQ3_XXS": 3.1, "Q2_K": 3.0, "IQ2_M": 2.7, "IQ2_XS": 2.3, "IQ2_XXS": 2.1, "IQ1_M": 1.8, "IQ1_S": 1.6,
}
// qualityCap is the precision past which a bigger file is not a better
// model worth its memory: eight bits keep what sixteen do, near enough. On a
// CPU the cap is lower: there every bit is read from memory for every token,
// so a file twice the size answers at half the speed, and a Q4_K_M or Q5 is
// the usual balance.
const (
qualityCap = 8.6
cpuQualityCap = 5.8
)
// quality scores an option's precision: rising to the cap, falling past it.
func quality(o Option) float64 {
if o.BitsPerWeight == 0 {
return 0
}
limit := qualityCap
if o.CPU {
limit = cpuQualityCap
}
if o.BitsPerWeight <= limit {
return o.BitsPerWeight
}
return limit - (o.BitsPerWeight-limit)*0.25
}
// Rank orders options best first by what the hardware is, not by what it is
// doing: on a GPU before the CPU; the fewest cards, then nodes; then the
// better precision, up to eight bits. Whether the cards are free now only
// breaks a tie, then the most room left.
//
// Busy is not unfit. Ranking what was free first made every file of every
// repository come out on the CPU on a cluster whose one card was in use —
// which turned down the very file the card was there for. A model served onto
// cards that are busy waits for one, or with its fallbacks runs elsewhere
// until then.
func Rank(opts []Option) {
sort.SliceStable(opts, func(i, j int) bool {
a, b := opts[i], opts[j]
if a.CPU != b.CPU {
return !a.CPU
}
if a.GPUs() != b.GPUs() {
return a.GPUs() < b.GPUs()
}
if a.Nodes != b.Nodes {
return a.Nodes < b.Nodes
}
if qa, qb := quality(a), quality(b); qa != qb {
return qa > qb
}
if a.FreeNow != b.FreeNow {
return a.FreeNow
}
return a.HaveGB-a.NeedGB > b.HaveGB-b.NeedGB
})
}
var quantPattern = regexp.MustCompile(`(?i)(IQ\d_[A-Z]+|Q\d_K_[SML]|Q\d_K|Q\d_\d|Q\d|BF16|F16|F32|FP16)`)
// QuantOf reads a quantisation out of a file name.
func QuantOf(name string) string {
m := quantPattern.FindAllString(name, -1)
if len(m) == 0 {
return ""
}
return strings.ToUpper(m[len(m)-1])
}
// MemFromClass reads the card size out of a class label such as "h100-80g",
// and a MIG instance's out of its profile: 40 for "a100-80g-mig-3g.40gb",
// whose card is 80 but whose instance holds 40 of it.
func MemFromClass(class string) int {
if _, profile, ok := strings.Cut(class, "-mig-"); ok {
var compute, gb int
if _, err := fmt.Sscanf(profile, "%dg.%dgb", &compute, &gb); err != nil {
return 0
}
return gb
}
i := strings.LastIndex(class, "-")
if i < 0 || !strings.HasSuffix(class, "g") {
return 0
}
var gb int
if _, err := fmt.Sscanf(class[i+1:], "%dg", &gb); err != nil {
return 0
}
return gb
}