Files
Ilker ManapandClaude Opus 5 300e298053 Work out whether a model fits the cluster, and how to split it
llmgw ctl model plan Qwen/Qwen2.5-Coder-32B-Instruct

The arithmetic this replaces is the last thing between having GPUs and
serving a model on them. Choosing tp, gpu_class and a context that will
actually fit means knowing the layer count, the KV head count, what the
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. I computed those by hand for
every model on this cluster.

Both halves are read rather than retyped: the architecture from the model's
own config.json and parameter count, the hardware from the running fleet.
Attention heads in place of KV heads is the classic slip and is worth a
factor of five on a model with grouped-query attention, so it is not a
number anyone should be entering.

Every candidate is listed, not one answer. With cards to spare you want the
full-precision fit; with one card free you want to know a quantised one
exists. Choosing between those is not the planner's job.

The tests are calibrated against files actually downloaded here — llama.cpp
reported 14.62 GiB and 8.37 GiB for the 14B at q8_0 and q4_K_M, and the
model reproduces both — and they caught two errors in my own arithmetic.
The overhead was charged per rank from the whole model's weights rather
than each rank's share, and it reserved a tenth of a card that
usableFraction had already reserved. Between them, Llama-3.3-70B at bf16
across two 80 GB cards came out as not fitting, which is a configuration
people run every day. Slack reserved in two places, each unaware of the
other, reads as caution and behaves as a bug.

Pools are grouped by class and fabric together, because a replica may never
span two fabrics: eight free cards split across two of them are not eight
cards for a model that needs eight, and merging them would plan something
that starts, passes its health checks and hangs on its first all-reduce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 07:46:53 +02:00

136 lines
4.4 KiB
Go

package modelplan
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// FetchModel reads a model's architecture from its repository.
//
// Read rather than asked for, because every field here is one somebody would
// otherwise have to look up and retype: the layer count, the KV head count, the
// head dimension and the parameter total. Those are exactly the numbers a
// person gets subtly wrong — attention heads instead of KV heads being the
// classic, and worth a factor of five on a model with grouped-query attention.
//
// Two documents: config.json for the architecture, and the repository index for
// the parameter count, which is the one number config.json does not carry.
func FetchModel(ctx context.Context, repo string) (Model, error) {
base := "https://huggingface.co"
cfg, err := fetchConfig(ctx, base, repo)
if err != nil {
return Model{}, err
}
m, err := cfg.toModel(repo)
if err != nil {
return Model{}, err
}
// Best effort. A repository that does not publish its parameter count is
// still usable — the caller can supply one — and failing the whole lookup
// over it would be worse than saying which number is missing.
if n, err := fetchParamCount(ctx, base, repo); err == nil && n > 0 {
m.Params = n
}
return m, nil
}
type hfConfig struct {
NumHiddenLayers int `json:"num_hidden_layers"`
NumAttentionHeads int `json:"num_attention_heads"`
NumKeyValueHeads int `json:"num_key_value_heads"`
HiddenSize int `json:"hidden_size"`
HeadDim int `json:"head_dim"`
MaxPositions int `json:"max_position_embeddings"`
ModelType string `json:"model_type"`
}
func (c hfConfig) toModel(repo string) (Model, error) {
if c.NumHiddenLayers == 0 {
return Model{}, fmt.Errorf("%s: config.json has no num_hidden_layers; is it a text model?", repo)
}
m := Model{
Name: repo[strings.LastIndex(repo, "/")+1:],
Layers: c.NumHiddenLayers,
MaxContext: c.MaxPositions,
}
// Grouped-query attention: when the KV head count is absent the model has
// as many as it has attention heads, which is multi-head attention and the
// older, more expensive arrangement.
m.KVHeads = c.NumKeyValueHeads
if m.KVHeads == 0 {
m.KVHeads = c.NumAttentionHeads
}
// head_dim is explicit in newer configs and derived in older ones. Derived
// from the attention heads, never the KV heads: the hidden size is split
// across the query heads.
m.HeadDim = c.HeadDim
if m.HeadDim == 0 {
if c.NumAttentionHeads == 0 {
return Model{}, fmt.Errorf("%s: config.json gives neither head_dim nor num_attention_heads", repo)
}
m.HeadDim = c.HiddenSize / c.NumAttentionHeads
}
return m, nil
}
func fetchConfig(ctx context.Context, base, repo string) (hfConfig, error) {
var c hfConfig
body, err := get(ctx, base+"/"+repo+"/resolve/main/config.json")
if err != nil {
return c, fmt.Errorf("reading %s: %w", repo, err)
}
if err := json.Unmarshal(body, &c); err != nil {
return c, fmt.Errorf("%s: config.json did not parse: %w", repo, err)
}
return c, nil
}
// fetchParamCount reads the parameter total from the repository's own index.
//
// Taken from the safetensors index rather than estimated from the architecture,
// because an estimate is wrong by whatever the embeddings and the tied weights
// come to, and that error lands directly on the weights figure — the largest
// term in the whole calculation.
func fetchParamCount(ctx context.Context, base, repo string) (int64, error) {
body, err := get(ctx, base+"/api/models/"+repo)
if err != nil {
return 0, err
}
var info struct {
Safetensors struct {
Total int64 `json:"total"`
} `json:"safetensors"`
}
if err := json.Unmarshal(body, &info); err != nil {
return 0, err
}
return info.Safetensors.Total, nil
}
func get(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// A private or gated repository is the common case here, and "404" on
// its own sends people looking for a typo they did not make.
return nil, fmt.Errorf("%s returned %d; a private or gated repository needs to be downloaded by hand",
url, resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 4<<20))
}