Files
llmgw/internal/web/explore_fit_test.go
T
Ilker ManapandClaude Opus 5.5 f2c6f12cfb How fast a model would answer on the cards, as this cluster would lay it out
For each layout modelplan.Options offers here — one card, tensor parallel
across a node's cards, llama.cpp's layer split, a pipeline across nodes on
a fabric — modelplan.GPUTokensPerSecond gives one reply's speed: the bytes
a token reads over each card's bandwidth, by its model name (a table, SXM
and PCIe apart), plus two all-reduces a layer for tensor parallel (8 us on
NVLink, 25 us and up on PCIe), turns between cards for a layer split, and
a hop per node over InfiniBand or RoCE for a pipeline. The explorer and a
repository's files show the fastest, with the layout; mixed classes are
judged class by class, FP8 on 8.9 cards only.

Calibrated on cuda01's Quadro RTX 6000 (672 GB/s): qwen3-vl-8b Q8, 8.7 GB,
62.1 tok/s measured, 59 estimated; Qwen3-Coder-30B-A3B Q4_K_M, 18.6 GB,
144 measured, which says a model of experts reads 1.6 times its active
share (ActiveShare now does, on the CPU too). Tensor parallel and fabrics
are not measured here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:19:36 +01:00

228 lines
9.8 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 web
import (
"html"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
)
// nodesFleet is a cluster of the given nodes, all ready.
type nodesFleet struct {
fakeFleet
nodes []adminapi.NodeView
}
func (f nodesFleet) Nodes() []adminapi.NodeView { return f.nodes }
func gpuNode(id, class string, cards int, cc string, ramGB int) adminapi.NodeView {
return adminapi.NodeView{ID: id, State: "ready", GPUTotal: cards, GPUFree: cards, MemoryGB: ramGB,
Classes: []string{class}, ClassGPUs: []adminapi.ClassGPUs{{Class: class, Total: cards, Free: cards}},
ComputeCapabilities: []string{cc}}
}
func cpuNode(id string, ramGB int) adminapi.NodeView {
return adminapi.NodeView{ID: id, State: "ready", MemoryGB: ramGB}
}
func stModel(id string, params map[string]int64, tags ...string) hf.Model {
var total int64
for dt, n := range params {
if dt != "I32" {
total += n
}
}
return hf.Model{ID: id, CreatedAt: time.Now(), LastModified: time.Now(), PipelineTag: "text-generation",
Tags: append([]string{"safetensors"}, tags...), Safetensors: &hf.SafetensorsInfo{Total: total, Parameters: params}}
}
// listing is what the explorer shows of these repositories on this cluster.
func listing(t *testing.T, nodes []adminapi.NodeView, ms ...hf.Model) (string, *fakeRegistry) {
t.Helper()
reg := &fakeRegistry{results: ms}
mux := mirrorPageLlama(t, MirrorDeps{Mirror: &fakeMirror{}, Registry: reg, Fleet: nodesFleet{nodes: nodes}})
return mirrorGet(t, mux, "/admin/mirror?tab=explore").Body.String(), reg
}
func TestTheExplorerListsWhatThisClustersHardwareHolds(t *testing.T) {
// The PoC: one Turing card of 24 GB, CPU nodes of up to 31 GB.
poc := []adminapi.NodeView{gpuNode("cuda01", "6000-24g", 1, "7.5", 251), cpuNode("node05", 31)}
body, reg := listing(t, poc,
ggufModel("a/Small-GGUF", "qwen3", 8e9, time.Now()),
ggufModel("a/Huge-GGUF", "qwen3moe", 671e9, time.Now()),
stModel("a/Eight-BF16", map[string]int64{"BF16": 8e9}),
stModel("a/ThirtyTwo-BF16", map[string]int64{"BF16": 32e9}),
stModel("a/Eight-FP8", map[string]int64{"F8_E4M3": 8e9, "BF16": 1e8}),
stModel("a/ThirtyTwo-AWQ", map[string]int64{"BF16": 1e9, "I32": 31e9}, "awq", "4-bit"),
stModel("a/Eight-MLX", map[string]int64{"U32": 8e9}, "mlx", "8-bit"),
)
if reg.query.GGUF || reg.query.Limit != 100 {
t.Errorf("with a card, safetensors are asked for too, more of them: %+v", reg.query)
}
for id, want := range map[string]bool{
"a/Small-GGUF": true, "a/Huge-GGUF": false,
"a/Eight-BF16": true, "a/ThirtyTwo-BF16": false,
"a/Eight-FP8": false, "a/ThirtyTwo-AWQ": true, "a/Eight-MLX": false,
} {
if got := strings.Contains(body, id+"<") || strings.Contains(body, id+"\""); got != want {
t.Errorf("%s listed = %v, want %v", id, got, want)
}
}
if !strings.Contains(body, "largest card has 24 GB (compute capability 7.5)") ||
!strings.Contains(body, "4 more are hidden") {
t.Errorf("the page should say what it judged by and how many it left out: %s", body)
}
}
func TestANewerClusterSeesTheModelsItCanRun(t *testing.T) {
// A customer's: eight H100s. What the PoC cannot run, this one lists.
h100 := []adminapi.NodeView{gpuNode("gpu01", "h100-80g", 8, "9.0", 1024)}
body, _ := listing(t, h100,
stModel("a/ThirtyTwo-BF16", map[string]int64{"BF16": 32e9}),
stModel("a/Eight-FP8", map[string]int64{"F8_E4M3": 8e9, "BF16": 1e8}),
ggufModel("a/Huge-GGUF", "qwen3moe", 671e9, time.Now()),
)
for _, id := range []string{"a/ThirtyTwo-BF16", "a/Eight-FP8", "a/Huge-GGUF"} {
if !strings.Contains(body, id) {
t.Errorf("%s should be listed on eight H100s", id)
}
}
}
func TestACPUOnlyClusterIsShownGGUFOnly(t *testing.T) {
body, reg := listing(t, []adminapi.NodeView{cpuNode("c1", 32)},
ggufModel("a/Small-GGUF", "qwen3", 8e9, time.Now()),
ggufModel("a/Big-GGUF", "qwen3", 70e9, time.Now()),
stModel("a/Eight-BF16", map[string]int64{"BF16": 8e9}),
)
if !reg.query.GGUF {
t.Errorf("without cards only GGUF runs, and should be asked for: %+v", reg.query)
}
if !strings.Contains(body, "a/Small-GGUF") || strings.Contains(body, "a/Big-GGUF") || strings.Contains(body, "a/Eight-BF16") {
t.Errorf("a 32 GB CPU node holds the 8B GGUF only: %s", body)
}
if !strings.Contains(body, "no GPUs, so GGUF only") {
t.Error("the page should say it judged by a cluster without cards")
}
}
func TestARepositorysFileTooBigForThisClusterIsNotOffered(t *testing.T) {
poc := []adminapi.NodeView{gpuNode("cuda01", "6000-24g", 1, "7.5", 251), cpuNode("node05", 31)}
reg := &fakeRegistry{repo: hf.Repo{ID: "a/M", Revision: "abc123def456", Params: 32e9,
Files: []hf.File{
{Path: "M-Q4_K_M.gguf", Bytes: 19 << 30},
{Path: "model-00001-of-00002.safetensors", Bytes: 32 << 30},
{Path: "model-00002-of-00002.safetensors", Bytes: 32 << 30},
}}}
mux := mirrorPageLlama(t, MirrorDeps{Mirror: &fakeMirror{}, Registry: reg, Fleet: nodesFleet{nodes: poc}})
body := mirrorGet(t, mux, "/admin/mirror?repo=a/M").Body.String()
if !strings.Contains(body, "/resolve/abc123def456/M-Q4_K_M.gguf") {
t.Errorf("the GGUF fits cuda01's RAM and should be offered: %s", body)
}
if strings.Contains(body, "/resolve/abc123def456/model-00001-of-00002.safetensors") ||
!strings.Contains(body, "too big for this cluster") {
t.Errorf("64 GB of safetensors on one 24 GB card should be marked, not offered: %s", body)
}
}
func TestPackedWeightsAreSizedAsTheyAreStored(t *testing.T) {
// gpt-oss-20b: MXFP4 in U8, counted by the registry at the weights it
// unpacks to. At a byte each it read as 22 GB and missed a 24 GB card.
oss := stModel("openai/gpt-oss-20b", map[string]int64{"BF16": 1804459584, "U8": 19110297600}, "8-bit", "mxfp4")
if gb := float64(oss.WeightBytes()) / (1 << 30); gb < 11 || gb > 13 {
t.Errorf("gpt-oss-20b sized %.1f GB, want about 12", gb)
}
// Tagged mlx, but with a GGUF too: the GGUF runs.
both := ggufModel("a/Both", "qwen3", 27e9, time.Now())
both.Tags = []string{"mlx", "safetensors", "gguf"}
poc := []adminapi.NodeView{gpuNode("cuda01", "6000-24g", 1, "7.5", 251)}
body, _ := listing(t, poc, oss, both)
if !strings.Contains(body, "openai/gpt-oss-20b") || !strings.Contains(body, "a/Both") {
t.Errorf("both run on cuda01: %s", body)
}
}
func TestTheExplorerSaysHowFastAModelAnswersOnACPU(t *testing.T) {
slow, fast := cpuNode("node01", 7), cpuNode("node05", 31)
slow.MemBandwidthGBs, fast.MemBandwidthGBs = 24.9, 41.9
body, _ := listing(t, []adminapi.NodeView{slow, fast},
ggufModel("Qwen/Qwen3-8B-GGUF", "qwen3", 8e9, time.Now()),
ggufModel("Qwen/Qwen3-30B-A3B-GGUF", "qwen3moe", 30e9, time.Now()),
)
// 8B at Q4_K_M is 4.8 GB, on node05's 41.9 GB/s: about 5.8 a second.
// The 30B model of experts reads 1.6 times its 3B active part: about 9.4.
for _, want := range []string{">~5.8<", ">~9.4<", "on node05&#39;s CPU"} {
if !strings.Contains(body, want) {
t.Errorf("the listing lacks %q: %s", want, body)
}
}
reg := &fakeRegistry{repo: hf.Repo{ID: "Qwen/Qwen3-8B-GGUF", Revision: "abc123def456", Params: 8e9,
Files: []hf.File{{Path: "Qwen3-8B-Q4_K_M.gguf", Bytes: 5 << 30}, {Path: "Qwen3-8B-Q8_0.gguf", Bytes: 8 << 30}}}}
mux := mirrorPageLlama(t, MirrorDeps{Mirror: &fakeMirror{}, Registry: reg,
Fleet: nodesFleet{nodes: []adminapi.NodeView{slow, fast}}})
page := html.UnescapeString(mirrorGet(t, mux, "/admin/mirror?repo=Qwen/Qwen3-8B-GGUF").Body.String())
if !strings.Contains(page, "~5.2 tok/s on node05's CPU") || !strings.Contains(page, "~3.3 tok/s on node05's CPU") {
t.Errorf("each file should say its speed on the CPU, by its own size: %s", page)
}
}
// cards is a node of n cards of a class, named as the driver names them,
// joined by NVLink or not, on a fabric or none.
func cards(id, class, name string, n int, cc string, nvlink bool, fabric string) adminapi.NodeView {
v := gpuNode(id, class, n, cc, 1024)
for i := 0; i < n; i++ {
g := adminapi.GPUView{Index: i, Name: name, Class: class}
if nvlink {
for j := 0; j < n; j++ {
if j != i {
g.NVLinkPeers = append(g.NVLinkPeers, j)
}
}
}
v.GPUs = append(v.GPUs, g)
}
if fabric != "" {
v.Fabrics = []string{fabric}
v.IBPorts = []adminapi.IBPortView{{HCA: "mlx5_0", Port: 1, Fabric: fabric, LinkLayer: "InfiniBand",
RateGbps: 200, Active: true}}
}
return v
}
func TestTheExplorerSaysHowFastAModelAnswersOnTheCards(t *testing.T) {
// The PoC's one Quadro RTX 6000: an 8B GGUF at Q4_K_M, 4.8 GB, on
// 672 GB/s.
poc := []adminapi.NodeView{cards("cuda01", "6000-24g", "Quadro RTX 6000", 1, "7.5", false, "")}
body, _ := listing(t, poc, ggufModel("Qwen/Qwen3-8B-GGUF", "qwen3", 8e9, time.Now()))
if !strings.Contains(body, `title="1× 6000-24g, llama.cpp">~103<`) {
t.Errorf("an 8B on cuda01's card should be about 103 a second: %s", body)
}
// A mixed site: eight H100s on NVLink, four L40S on PCIe. 70B in BF16
// fits two H100s or four L40S, and the planner takes the fewest cards:
// the H100s are the faster.
mixed := []adminapi.NodeView{
cards("h", "h100-80g", "NVIDIA H100 80GB HBM3", 8, "9.0", true, ""),
cards("l", "l40s-48g", "NVIDIA L40S", 4, "8.9", false, ""),
}
body, _ = listing(t, mixed, stModel("meta-llama/Llama-3.3-70B-Instruct", map[string]int64{"BF16": 70e9}))
if !strings.Contains(body, "2× h100-80g, NVLink, vLLM") {
t.Errorf("70B should run fastest on two NVLinked H100s: %s", body)
}
// Two nodes of one A100 each on InfiniBand: 70B fits neither, and the
// pipeline across them is what the planner offers.
ib := []adminapi.NodeView{
cards("a", "a100-80g", "NVIDIA A100-SXM4-80GB", 1, "8.0", false, "ib0"),
cards("b", "a100-80g", "NVIDIA A100-SXM4-80GB", 1, "8.0", false, "ib0"),
}
body, _ = listing(t, ib, stModel("meta-llama/Llama-3.3-70B-Instruct", map[string]int64{"BF16": 70e9}))
if !strings.Contains(body, "2 nodes × 1× a100-80g over ib0 (InfiniBand 200 Gb/s), vLLM") {
t.Errorf("70B should be laid out across both nodes over ib0: %s", body)
}
}