A mixture-of-experts model on more than one card is served with vLLM's --enable-expert-parallel; config.json's experts are read by any of their names, and a model that also reads pictures is sized by its text_config. Tensor parallelism past the KV heads is allowed when it is a multiple of them (Qwen3-235B, four KV heads, runs on eight cards), which the plan had refused. Several nodes are offered in any number, not only powers of two. Each way says what it needs of a card, part by part, and of all its cards; the serve form checks a context longer than planned still fits. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
345 lines
12 KiB
Go
345 lines
12 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
|
|
)
|
|
|
|
// newModelPlanCmd answers whether a model fits this cluster, and how.
|
|
//
|
|
// The arithmetic it replaces is the last thing standing between having GPUs and
|
|
// serving a model on them. Deciding tp, gpu_class and a context that will
|
|
// actually fit means knowing the model's layer count, its KV head count, what
|
|
// its weights weigh at a chosen quantisation, and how much of a card the
|
|
// runtime keeps — and getting it wrong is not an error message, it is an
|
|
// out-of-memory twenty minutes into a load.
|
|
//
|
|
// It reads the model from its own repository and the hardware from the running
|
|
// fleet, so neither half is a number somebody retyped.
|
|
func newModelPlanCmd(client func() *ctlClient) *cobra.Command {
|
|
var (
|
|
context int
|
|
params int64
|
|
precs []string
|
|
format string
|
|
)
|
|
c := &cobra.Command{
|
|
Use: "plan <huggingface-repo>",
|
|
Short: "Work out which of a model's files suits this cluster, and how it should be split",
|
|
Long: "Lists every file the repository has — each GGUF quantisation, or with\n" +
|
|
"--format safetensors its original weights — with the best way each could\n" +
|
|
"run here: on one card, on several of a node's, over several nodes on a\n" +
|
|
"fabric, or on a CPU node; what each card would hold and whether the cards\n" +
|
|
"are free now. The model's shape is read from one file's header, nothing is\n" +
|
|
"downloaded. `llmgw ctl model serve` without --quant takes the first.\n\n" +
|
|
"With --precision it plans in the abstract instead, from the model's\n" +
|
|
"config: every way it could run at bf16, fp8, q8_0 or q4_k_m, fewest GPUs\n" +
|
|
"first, and a catalog entry for the best one.\n\n" +
|
|
"Every candidate is listed rather than one answer, because the choice is\n" +
|
|
"not the planner's to make: with cards to spare you want the\n" +
|
|
"full-precision fit, and with one card free you want to know a quantised\n" +
|
|
"one exists.",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
repo := args[0]
|
|
// The files the repository has, against the fleet as it is —
|
|
// unless a precision or a size is given to plan in the abstract.
|
|
if len(precs) == 0 && params == 0 {
|
|
return planFiles(cmd, client, repo, format, context)
|
|
}
|
|
m, err := modelplan.FetchModel(cmd.Context(), repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if params > 0 {
|
|
m.Params = params
|
|
}
|
|
if m.Params == 0 {
|
|
return fmt.Errorf("%s does not publish a parameter count; pass --params", repo)
|
|
}
|
|
|
|
var chosen []modelplan.Precision
|
|
for _, name := range precs {
|
|
p, err := modelplan.PrecisionByName(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
chosen = append(chosen, p)
|
|
}
|
|
|
|
var nodes []adminapi.NodeView
|
|
if err := client().get(cmd.Context(), "/api/v1/nodes", &nodes); err != nil {
|
|
return err
|
|
}
|
|
hw := hardwareFrom(nodes)
|
|
if len(hw) == 0 {
|
|
return fmt.Errorf("no node is ready, so there is nothing to plan against")
|
|
}
|
|
|
|
out := cmd.OutOrStdout()
|
|
fmt.Fprintf(out, "%s: %.1fB parameters, %d layers, %d KV heads, trained to %d tokens\n",
|
|
m.Name, float64(m.Params)/1e9, m.Layers, m.KVHeads, m.MaxContext)
|
|
|
|
want := context
|
|
if want <= 0 {
|
|
want = m.MaxContext
|
|
}
|
|
fmt.Fprintf(out, "Planned for a %d-token pool. %s a token of context.\n\n",
|
|
want, humanBytes(m.KVBytesPerToken(modelplan.BF16)))
|
|
|
|
fits := modelplan.Fits(m, hw, want, chosen)
|
|
if len(fits) == 0 {
|
|
fmt.Fprintln(out, "It does not fit this cluster at any offered precision.")
|
|
fmt.Fprintln(out, "\nWhat would change that:")
|
|
fmt.Fprintln(out, " - a smaller context pool, with --context")
|
|
fmt.Fprintln(out, " - a heavier quantisation, with --precision q4_k_m")
|
|
fmt.Fprintln(out, " - more cards free; llmgw ctl status shows what is in use")
|
|
return errDoesNotFit
|
|
}
|
|
|
|
w := tab()
|
|
fmt.Fprintln(w, " GPUS\tPRECISION\tPER CARD\tFREE\tWHERE")
|
|
for _, f := range fits {
|
|
where := f.Class
|
|
if f.CPUOnly {
|
|
where = "cpu"
|
|
} else if f.Nodes > 1 {
|
|
where = fmt.Sprintf("%s, %d nodes on %s", f.Class, f.Nodes, f.Fabric)
|
|
}
|
|
fmt.Fprintf(w, " %d\t%s\t%.1f GB\t%.1f GB\t%s\n",
|
|
f.TP, f.Precision.Name, f.PerGPUGB, f.Headroom, where)
|
|
}
|
|
_ = w.Flush()
|
|
|
|
fmt.Fprintf(out, "\nA catalog entry for the first of those:\n\n%s\n",
|
|
catalogEntry(m, fits[0], repo, want))
|
|
fmt.Fprintln(out, "Save it and add it with: llmgw ctl model add -f <file>")
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().IntVar(&context, "context", 0, "token pool to plan for (default: the model's trained context)")
|
|
c.Flags().Int64Var(¶ms, "params", 0, "parameter count, when the repository does not publish one")
|
|
c.Flags().StringSliceVar(&precs, "precision", nil, "plan in the abstract, for these precisions (bf16, fp8, q8_0, q4_k_m), "+
|
|
"from the model's config rather than its files")
|
|
c.Flags().StringVar(&format, "format", "gguf", "gguf files, for llama.cpp, or safetensors, the original weights for vLLM")
|
|
return c
|
|
}
|
|
|
|
// errDoesNotFit gives a script something to test without parsing prose.
|
|
var errDoesNotFit = fmt.Errorf("the model does not fit this cluster")
|
|
|
|
// hardwareFrom turns the fleet into pools a replica could actually occupy.
|
|
//
|
|
// Grouped by class and fabric together, because a replica may never span two
|
|
// fabrics: eight free cards split across two fabrics are not eight cards for a
|
|
// model that needs eight, and treating them as one pool would plan something
|
|
// that hangs on its first all-reduce.
|
|
func hardwareFrom(nodes []adminapi.NodeView) []modelplan.Hardware {
|
|
type key struct{ class, fabric string }
|
|
pools := map[key]*modelplan.Hardware{}
|
|
|
|
for _, n := range nodes {
|
|
if n.State != "ready" {
|
|
continue
|
|
}
|
|
fabric := ""
|
|
if len(n.Fabrics) > 0 {
|
|
fabric = n.Fabrics[0]
|
|
}
|
|
// Each class of the node's devices is a pool of its own: a node with
|
|
// a card in MIG mode has its instances' classes, and any whole card's
|
|
// beside them. A node that does not count by class is its first
|
|
// class, as it always was.
|
|
kinds := n.ClassGPUs
|
|
if len(kinds) == 0 {
|
|
class := ""
|
|
if len(n.Classes) > 0 {
|
|
class = n.Classes[0]
|
|
}
|
|
kinds = []adminapi.ClassGPUs{{Class: class, Total: n.GPUTotal, Free: n.GPUFree}}
|
|
}
|
|
for _, kind := range kinds {
|
|
class := kind.Class
|
|
k := key{class, fabric}
|
|
if pools[k] == nil {
|
|
pools[k] = &modelplan.Hardware{Class: class, Fabric: fabric}
|
|
}
|
|
p := pools[k]
|
|
p.Nodes++
|
|
// The widest a single node could contribute, not the total: a
|
|
// tensor group takes the same number of cards from every node it
|
|
// spans.
|
|
if kind.Free > p.GPUsPerNode {
|
|
p.GPUsPerNode = kind.Free
|
|
}
|
|
if n.MemoryGB > p.HostMemGB {
|
|
p.HostMemGB = n.MemoryGB
|
|
}
|
|
// Card memory from the class label, which carries it by
|
|
// construction: "h100-80g" is eighty gigabytes and says so, and
|
|
// "a100-80g-mig-3g.40gb" is an instance of forty.
|
|
if class != "" && p.GPUMemGB == 0 {
|
|
p.GPUMemGB = memFromClass(class)
|
|
}
|
|
}
|
|
}
|
|
|
|
out := make([]modelplan.Hardware, 0, len(pools))
|
|
for _, p := range pools {
|
|
if p.Class != "" && (p.GPUMemGB == 0 || p.GPUsPerNode == 0) {
|
|
continue // a GPU pool with no free cards is not a pool
|
|
}
|
|
out = append(out, *p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// memFromClass reads the card size out of a class label such as "h100-80g".
|
|
func memFromClass(class string) int { return modelplan.MemFromClass(class) }
|
|
|
|
// catalogEntry renders a fit as the YAML that would serve it.
|
|
func catalogEntry(m modelplan.Model, f modelplan.Fit, source string, context int) string {
|
|
var b strings.Builder
|
|
name := strings.ToLower(m.Name)
|
|
fmt.Fprintf(&b, "name: %s\n", name)
|
|
if f.Precision.GGUF {
|
|
// A GGUF quantisation lives in a file, not a repository, and llama.cpp
|
|
// is given a path. Printing the repository id here would produce an
|
|
// entry that looks right and cannot load — which is the failure this
|
|
// whole command exists to prevent.
|
|
fmt.Fprintf(&b, "# %s is a GGUF quantisation: download the %s file and give its path.\n",
|
|
f.Precision.Name, f.Precision.Name)
|
|
fmt.Fprintf(&b, "source: /var/lib/llmgw-models/%s-%s.gguf\n", name, f.Precision.Name)
|
|
} else {
|
|
fmt.Fprintf(&b, "source: %s\n", source)
|
|
}
|
|
fmt.Fprintf(&b, "routing_profile: chat\n")
|
|
fmt.Fprintf(&b, "context_length: %d\n", context)
|
|
fmt.Fprintf(&b, "scaling: { min: 1, max: 1 }\n")
|
|
fmt.Fprintf(&b, "profiles:\n")
|
|
|
|
switch {
|
|
case f.CPUOnly:
|
|
fmt.Fprintf(&b, " - name: cpu\n engine: %s\n", f.Precision.Engine)
|
|
fmt.Fprintf(&b, " min_memory_gb: %d\n", int(f.PerGPUGB)+2)
|
|
default:
|
|
fmt.Fprintf(&b, " - name: tp%d\n engine: %s\n", f.TP, f.Precision.Engine)
|
|
fmt.Fprintf(&b, " gpu_class: %s\n tp: %d\n", f.Class, f.TP)
|
|
if f.Nodes > 1 {
|
|
fmt.Fprintf(&b, " nodes: %d\n gpus_per_node: %d\n", f.Nodes, f.GPUsPerNode)
|
|
}
|
|
if q := f.Precision.Quantization; q != "" {
|
|
fmt.Fprintf(&b, " args: [\"--quantization\", \"%s\"]\n", q)
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, " priority: 100\n weight: 1\n")
|
|
// Measured rather than guessed is the rule everywhere else in this system,
|
|
// and a planner should say so rather than pretend its estimate is one.
|
|
fmt.Fprintf(&b, " startup_timeout: 10m\n")
|
|
b.WriteString("\n# weight should come from `llmgw ctl bench` once it is running;\n")
|
|
b.WriteString("# 1 is a placeholder, not a measurement.\n")
|
|
return b.String()
|
|
}
|
|
|
|
// humanBytes renders a per-token cache size, which is always small.
|
|
func humanBytes(n int64) string {
|
|
switch {
|
|
case n >= 1<<20:
|
|
return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20))
|
|
default:
|
|
return fmt.Sprintf("%.0f KiB", float64(n)/1024)
|
|
}
|
|
}
|
|
|
|
// planFiles prints the control plane's plan of a repository's files.
|
|
func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string, context int) error {
|
|
q := url.Values{"repo": {repo}, "format": {format}}
|
|
if context > 0 {
|
|
q.Set("context", strconv.Itoa(context))
|
|
}
|
|
var p modelserve.Plan
|
|
if err := client().get(cmd.Context(), "/api/v1/plan?"+q.Encode(), &p); err != nil {
|
|
return err
|
|
}
|
|
out := cmd.OutOrStdout()
|
|
fmt.Fprintf(out, "%s at %s", p.Repo, catalog.ShortRevision(p.Revision))
|
|
if p.Arch != "" {
|
|
fmt.Fprintf(out, ", %s", p.Arch)
|
|
}
|
|
if p.Params > 0 {
|
|
fmt.Fprintf(out, ", %.1fB parameters", float64(p.Params)/1e9)
|
|
}
|
|
fmt.Fprintf(out, "; planned for a %d-token context\n", p.Context)
|
|
if p.Gated {
|
|
fmt.Fprintln(out, "gated: the mirror cannot fetch it without an account's approval")
|
|
}
|
|
if !p.Servable {
|
|
fmt.Fprintf(out, "the nodes' engine cannot run it: %s\n", p.Reason)
|
|
return errDoesNotFit
|
|
}
|
|
if p.Note != "" {
|
|
fmt.Fprintln(out, "note: "+p.Note)
|
|
}
|
|
fmt.Fprintln(out)
|
|
w := tabTo(out)
|
|
fmt.Fprintln(w, "FILE\tSIZE\tBEST WAY\tA CARD\tOF\tALL CARDS\tNOW")
|
|
for _, a := range p.Artifacts {
|
|
name := a.Name
|
|
if a.Shards > 1 && a.Format == "gguf" {
|
|
name += fmt.Sprintf(" (%d parts)", a.Shards)
|
|
}
|
|
if a.Best == nil {
|
|
fmt.Fprintf(w, "%s\t%s\tfits nowhere\t\t\t\t\n", name, bytesShort(a.Bytes))
|
|
continue
|
|
}
|
|
b := a.Best
|
|
now := "busy"
|
|
if b.FreeNow {
|
|
now = "free"
|
|
}
|
|
holds, all := fmt.Sprintf("%.1f GB", b.NeedGB), fmt.Sprintf("%.0f GB", b.TotalGB())
|
|
if b.CPU {
|
|
holds, all = fmt.Sprintf("%.1f GB of RAM", b.NeedGB), "-"
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.0f GB\t%s\t%s\n", name, bytesShort(a.Bytes), b.Label(), holds, b.HaveGB, all, now)
|
|
}
|
|
_ = w.Flush()
|
|
rec, ok := p.Recommended()
|
|
if !ok {
|
|
fmt.Fprintln(out, "\nNo file fits this cluster as it is: a smaller context (--context), or more memory.")
|
|
return errDoesNotFit
|
|
}
|
|
fmt.Fprintf(out, "\nBest: %s on %s: %s.\n", rec.Artifact, rec.Label(), rec.MemoryText())
|
|
if p.KVBytesPerToken > 0 {
|
|
fmt.Fprintf(out, "The context costs %.2f GB per 1000 tokens, the whole model (--context to plan another).\n",
|
|
modelplan.GB(p.KVBytesPerToken*1000))
|
|
}
|
|
if len(p.Options) > 1 {
|
|
fmt.Fprintln(out, "\nEvery way, best first:")
|
|
w = tabTo(out)
|
|
for _, o := range p.Options {
|
|
now := "busy"
|
|
if o.FreeNow {
|
|
now = "free"
|
|
}
|
|
fmt.Fprintf(w, " %s\t%s\t%s\t%d host(s)\t%s\n", o.Artifact, o.Label(), o.MemoryText(), o.Hosts, now)
|
|
}
|
|
_ = w.Flush()
|
|
}
|
|
fmt.Fprintf(out, "\nServe it: llmgw ctl model serve %s", p.Repo)
|
|
if p.Format == "safetensors" {
|
|
fmt.Fprint(out, " --format safetensors")
|
|
}
|
|
fmt.Fprintln(out, " --fallbacks")
|
|
return nil
|
|
}
|