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>
161 lines
5.5 KiB
Go
161 lines
5.5 KiB
Go
package modelplan
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
// Real models, with the figures measured on this cluster rather than derived
|
|
// from a formula — the point of testing arithmetic is that it agrees with the
|
|
// world, and a test built from the same formula would agree with itself.
|
|
var (
|
|
coder3B = Model{
|
|
Name: "Qwen2.5-Coder-3B", Params: 3_090_000_000,
|
|
Layers: 36, KVHeads: 2, HeadDim: 128, MaxContext: 32768,
|
|
}
|
|
coder14B = Model{
|
|
Name: "Qwen2.5-Coder-14B", Params: 14_770_000_000,
|
|
Layers: 48, KVHeads: 8, HeadDim: 128, MaxContext: 32768,
|
|
}
|
|
llama70B = Model{
|
|
Name: "Llama-3.3-70B", Params: 70_600_000_000,
|
|
Layers: 80, KVHeads: 8, HeadDim: 128, MaxContext: 131072,
|
|
}
|
|
)
|
|
|
|
func closeTo(t *testing.T, what string, got, want, tolerance float64) {
|
|
t.Helper()
|
|
if math.Abs(got-want) > tolerance {
|
|
t.Errorf("%s = %.2f, want %.2f (±%.2f)", what, got, want, tolerance)
|
|
}
|
|
}
|
|
|
|
func TestKVCachePerTokenMatchesTheArchitecture(t *testing.T) {
|
|
// 192 KiB a token for the 14B is the number that made sense of what this
|
|
// cluster measured: 8192 of context across four llama.cpp slots is 2048
|
|
// each, and six gigabytes of cache at the model's full 32k.
|
|
if got := coder14B.KVBytesPerToken(BF16); got != 192*1024 {
|
|
t.Errorf("14B KV/token = %d bytes, want %d", got, 192*1024)
|
|
}
|
|
// Grouped-query attention is why this is KVHeads and not the attention
|
|
// head count. The 14B has 40 attention heads and 8 KV heads, and paying for
|
|
// 40 would overstate the cache fivefold.
|
|
if got := coder3B.KVBytesPerToken(BF16); got != 36*1024 {
|
|
t.Errorf("3B KV/token = %d bytes, want %d", got, 36*1024)
|
|
}
|
|
if got := llama70B.KVBytesPerToken(BF16); got != 320*1024 {
|
|
t.Errorf("70B KV/token = %d bytes, want %d", got, 320*1024)
|
|
}
|
|
}
|
|
|
|
func TestQuantisedWeightsMatchTheFilesOnDisk(t *testing.T) {
|
|
// Calibrated against the GGUFs downloaded to this cluster, so the
|
|
// bytes-per-parameter figures are measurements rather than folklore.
|
|
// In GiB, which is what llama-bench itself reported for these files —
|
|
// "14.62 GiB" and "8.37 GiB". The download sizes quoted in decimal GB are
|
|
// the same bytes in different units, and mixing the two is how a plan ends
|
|
// up seven percent optimistic.
|
|
for _, tc := range []struct {
|
|
p Precision
|
|
want float64 // GiB, as llama.cpp accounts for it
|
|
}{
|
|
{Q8GGUF, 14.62},
|
|
{Q4GGUF, 8.37},
|
|
} {
|
|
got := GB(coder14B.Needs(tc.p, 0).Weights)
|
|
closeTo(t, "14B "+tc.p.Name+" weights", got, tc.want, 0.35)
|
|
}
|
|
// And full precision, which is two bytes a parameter by definition.
|
|
closeTo(t, "14B bf16 weights", GB(coder14B.Needs(BF16, 0).Weights), 27.5, 0.6)
|
|
}
|
|
|
|
func TestContextDominatesAtLongWindows(t *testing.T) {
|
|
// The reason KV cache is worth computing rather than hand-waving: at 128k
|
|
// of pooled context a 70B's cache is a substantial fraction of its weights,
|
|
// and a plan that ignores it fits a model that then will not load.
|
|
short := llama70B.Needs(BF16, 4096)
|
|
long := llama70B.Needs(BF16, 131072)
|
|
if short.Weights != long.Weights {
|
|
t.Fatal("weights should not depend on context")
|
|
}
|
|
if GB(long.KVCache) < 35 {
|
|
t.Errorf("70B KV at 128k = %.1f GB, expected tens of GB", GB(long.KVCache))
|
|
}
|
|
if long.KVCache <= short.KVCache*8 {
|
|
t.Error("KV cache should scale with the context pool")
|
|
}
|
|
}
|
|
|
|
func TestTensorParallelismDividesWeightsAndCacheButNotOverhead(t *testing.T) {
|
|
// Each rank holds its share of the weights and of the KV heads, so both
|
|
// divide. The runtime does not: every process pays its own CUDA context and
|
|
// activation buffers, which is why eight-way parallelism does not make a
|
|
// model eight times cheaper per card.
|
|
d := llama70B.Needs(BF16, 32768)
|
|
|
|
one := d.PerGPU(1)
|
|
eight := d.PerGPU(8)
|
|
if eight >= one {
|
|
t.Fatal("splitting across GPUs should reduce the per-GPU demand")
|
|
}
|
|
// Strictly more than an eighth, because the overhead is not shared.
|
|
if eight <= one/8 {
|
|
t.Errorf("per-GPU at tp=8 is %d, at or below a clean eighth of %d: overhead is being divided",
|
|
eight, one)
|
|
}
|
|
if d.PerGPU(0) != d.PerGPU(1) {
|
|
t.Error("a nonsensical GPU count should be treated as one, not divided by zero")
|
|
}
|
|
}
|
|
|
|
func TestA70BNeedsMoreThanOneCard(t *testing.T) {
|
|
// The question someone with hardware actually asks, and the answer that is
|
|
// easy to get wrong by hand: 70B at bf16 does not fit an 80 GB card, and
|
|
// does fit two.
|
|
d := llama70B.Needs(BF16, 32768)
|
|
const h100 = 80.0
|
|
|
|
if GB(d.PerGPU(1)) <= h100 {
|
|
t.Errorf("70B bf16 reported as fitting one 80 GB card (%.1f GB)", GB(d.PerGPU(1)))
|
|
}
|
|
if GB(d.PerGPU(2)) > h100 {
|
|
t.Errorf("70B bf16 across two cards is %.1f GB each, which should fit 80", GB(d.PerGPU(2)))
|
|
}
|
|
}
|
|
|
|
func TestOverheadIsChargedEvenWithNoContext(t *testing.T) {
|
|
// A plan that forgets the runtime fits a model into exactly its weights and
|
|
// then watches it fail to load.
|
|
d := coder3B.Needs(BF16, 0)
|
|
if d.Overhead <= 0 {
|
|
t.Error("no overhead was charged")
|
|
}
|
|
if d.Total() <= d.Weights {
|
|
t.Error("the total is not more than the weights alone")
|
|
}
|
|
}
|
|
|
|
func TestPrecisionByNameRefusesWhatItDoesNotKnow(t *testing.T) {
|
|
if _, err := PrecisionByName("bf16"); err != nil {
|
|
t.Errorf("bf16 was refused: %v", err)
|
|
}
|
|
_, err := PrecisionByName("fp3")
|
|
if err == nil {
|
|
t.Fatal("an unknown precision was accepted")
|
|
}
|
|
// The error lists what would work, because the caller mistyped something
|
|
// and a bare refusal makes them go looking.
|
|
if got := err.Error(); !contains(got, "q4_k_m") || !contains(got, "bf16") {
|
|
t.Errorf("the error does not say what is known: %q", got)
|
|
}
|
|
}
|
|
|
|
func contains(s, sub string) bool {
|
|
for i := 0; i+len(sub) <= len(s); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|