CI / test (push) Successful in 1m39s
Reading the tool's own output against the real fleet: it chose q8_0 as the best fit and emitted a vLLM profile with no quantisation. q8_0 is a GGUF quantisation that only llama.cpp serves, and from a file rather than a repository — so following that entry loads full-precision weights, which the same output had just listed as not fitting one card. The planner would have caused the out-of-memory it exists to prevent, from a recommendation that looked right. Choosing a precision and choosing an engine turn out to be one decision, so a Precision now carries the engine that serves it, the quantisation flag where one is needed, and whether it lives in a file. A GGUF fit prints a path and says to download it; a vLLM fit prints the repository and passes --quantization so the engine does not quietly load bf16. The arithmetic was right and the recommendation was wrong, which is the failure mode worth having tests for: everything reported a number, and the numbers agreed with each other. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
241 lines
8.2 KiB
Go
241 lines
8.2 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"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
|
|
)
|
|
c := &cobra.Command{
|
|
Use: "plan <huggingface-repo>",
|
|
Short: "Work out whether a model fits this cluster, and how it should be split",
|
|
Long: "Reads the model's architecture from its repository and the hardware from\n" +
|
|
"the running fleet, then prints every way it could run — 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]
|
|
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, "precisions to consider (default: bf16, fp8, q8_0, q4_k_m)")
|
|
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]
|
|
}
|
|
class := ""
|
|
if len(n.Classes) > 0 {
|
|
class = n.Classes[0]
|
|
}
|
|
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 n.GPUFree > p.GPUsPerNode {
|
|
p.GPUsPerNode = n.GPUFree
|
|
}
|
|
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.
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|