Files
Ilker ManapandClaude Opus 5.5 c866b3a8ee
CI / test (push) Failing after 6m42s
How much of each GPU works, beside whether anything does
The busy figure is nvidia-smi's utilization.gpu: the share of time any
kernel ran, which a kernel on one SM fills. On cuda01 decode read 92% busy
while the SMs had work 69% of the time and issued on 18% of their cycles.

Busy is now said to be that, and beside it: each card's draw against its
power limit (efficiency table, fleet and node charts, the console's GPU
work chart), and where the profiling counters can be read, SMs active, SM
occupancy, tensor cores and DRAM (llmgw_gpu_*_active_percent), from NVML's
GPM on Hopper and later or a dcgm-exporter on the node
(discovery.dcgm_exporter). The GPU map colours by work by default and keeps
busy as a mode. A MIG instance is never published with its card's profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:11:26 +01:00

230 lines
7.5 KiB
Go

package web
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"slices"
"sort"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// nodePageData is one node: what it is, how busy it is now, and how busy it
// has been.
type nodePageData struct {
Nav Nav
OnAdminListener bool
Node adminapi.NodeView
Engines []nodeEngine
// GPUModels are the models on each of the node's GPUs, by index.
GPUModels map[int][]string
// EngineVersions are the installed engines and their versions, sorted.
EngineVersions []string
Charts *nodeCharts
HasHistory bool
Back, Logs string
// Self is this page's address, for the in-place refresh.
Self string
Version, Now string
RefreshSec int
// RefreshMeta is the reload for a browser without script.
RefreshMeta template.HTML
}
// nodeEngine is one engine running on the node, and its share of it.
type nodeEngine struct {
Replica, Model, Shard, State string
GPUs []int
CPUCores float64
MemoryBytes int64
// CPUShare is its cores as a percentage of the machine, for the bar.
CPUShare float64
}
// nodeCharts are the node's recent history, from the metrics store.
type nodeCharts struct {
CPU, Memory, Load, EngineCPU, EngineMemory, Queue TimeSeries
// The GPUs', one line per card; empty on a node without them.
GPUUtil, GPUMemory, GPUTemp, GPUPower TimeSeries
// GPUPowerLoad is draw against the power limit; GPUSMActive the SMs'
// share of time with work, empty on a card nothing can profile.
GPUPowerLoad, GPUSMActive TimeSeries
Window string
Err string
}
// nodePanels are the charts, each a query scoped to this node. %s is the
// node's ID, quoted.
var nodePanels = []struct {
expr, nameBy, unit string
into func(*nodeCharts, TimeSeries)
}{
// Divided to a fraction because that is what the "%" unit draws.
{`llmgw_node_cpu_utilization_percent{node=%s} / 100`, "node", "%",
func(c *nodeCharts, t TimeSeries) { c.CPU = t }},
{`llmgw_node_memory_used_bytes{node=%s}`, "node", "bytes",
func(c *nodeCharts, t TimeSeries) { c.Memory = t }},
{`llmgw_node_load{node=%s}`, "window", "",
func(c *nodeCharts, t TimeSeries) { c.Load = t }},
{`sum by (model) (llmgw_shard_cpu_cores{node=%s})`, "model", "",
func(c *nodeCharts, t TimeSeries) { c.EngineCPU = t }},
{`sum by (model) (llmgw_shard_memory_bytes{node=%s})`, "model", "bytes",
func(c *nodeCharts, t TimeSeries) { c.EngineMemory = t }},
}
// gpuPanels are the GPUs' charts, one line per card, for a node that has
// them. Read from the heartbeat's samples, not from what the node said when
// it registered.
var gpuPanels = []struct {
expr, unit string
into func(*nodeCharts, TimeSeries)
}{
{`llmgw_gpu_utilization_percent{node=%s} / 100`, "%",
func(c *nodeCharts, t TimeSeries) { c.GPUUtil = t }},
{`llmgw_gpu_memory_used_bytes{node=%s}`, "bytes",
func(c *nodeCharts, t TimeSeries) { c.GPUMemory = t }},
{`llmgw_gpu_temperature_celsius{node=%s}`, "",
func(c *nodeCharts, t TimeSeries) { c.GPUTemp = t }},
{`llmgw_gpu_power_watts{node=%s}`, "",
func(c *nodeCharts, t TimeSeries) { c.GPUPower = t }},
{`llmgw_gpu_power_watts{node=%[1]s} / llmgw_gpu_power_limit_watts{node=%[1]s}`, "%",
func(c *nodeCharts, t TimeSeries) { c.GPUPowerLoad = t }},
{`llmgw_gpu_sm_active_percent{node=%s} / 100`, "%",
func(c *nodeCharts, t TimeSeries) { c.GPUSMActive = t }},
}
// NodePage shows one node. Its charts come from the metrics store when one
// is configured; the current figures come from the node's last heartbeat
// either way.
func NodePage(src Source, history History, window time.Duration, onAdminListener bool,
version string, log *slog.Logger,
) http.HandlerFunc {
if window <= 0 {
window = time.Hour
}
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
d := nodePageData{
Nav: navFrom(r, "cluster"), OnAdminListener: onAdminListener,
Back: "/admin", Logs: "/admin/nodes/" + id + "/logs", Self: "/admin/nodes/" + id,
HasHistory: history != nil, Version: version, RefreshSec: 15, RefreshMeta: refreshMeta(15),
Now: time.Now().UTC().Format(time.RFC3339),
}
l := langOf(r)
if onAdminListener {
d.Back, d.Logs, d.Self = "/", "/nodes/"+id+"/logs", "/nodes/"+id
}
found := false
for _, n := range src.Nodes() {
if n.ID == id {
d.Node, found = n, true
d.Node.LastSeenAgo = l.clockText(n.LastSeenAgo)
break
}
}
// Only a node the control plane knows reaches a query below, which
// is what keeps the ID a name rather than something typed into PromQL.
if !found {
http.NotFound(w, r)
return
}
var replicas []string
for _, rep := range src.Replicas("") {
for _, sh := range rep.Shards {
if sh.Node != id {
continue
}
e := nodeEngine{Replica: rep.ID, Model: rep.Model, Shard: sh.ID, State: sh.State,
GPUs: sh.GPUs, CPUCores: sh.CPUCores, MemoryBytes: sh.MemoryBytes}
for _, g := range sh.GPUs {
if d.GPUModels == nil {
d.GPUModels = map[int][]string{}
}
if !slices.Contains(d.GPUModels[g], rep.Model) {
d.GPUModels[g] = append(d.GPUModels[g], rep.Model)
}
}
if l := d.Node.Load; l != nil && l.CPUs > 0 {
e.CPUShare = sh.CPUCores * 100 / float64(l.CPUs)
}
d.Engines = append(d.Engines, e)
replicas = append(replicas, rep.ID)
}
}
sort.Slice(d.Engines, func(i, j int) bool { return d.Engines[i].CPUCores > d.Engines[j].CPUCores })
for e, v := range d.Node.EngineVersions {
d.EngineVersions = append(d.EngineVersions, e+" "+v)
}
sort.Strings(d.EngineVersions)
if history != nil {
d.Charts = loadNodeCharts(r.Context(), l, history, id, replicas, d.Node.GPUTotal, window, log)
}
noStore(w)
render(w, r, "node", d)
}
}
func loadNodeCharts(ctx context.Context, l *Lang, h History, node string, replicas []string, gpus int,
window time.Duration, log *slog.Logger,
) *nodeCharts {
step := window / 60
out := &nodeCharts{Window: l.windowWord(window)}
quoted := fmt.Sprintf("%q", node)
for _, p := range nodePanels {
series, err := h.RangeQuery(ctx, fmt.Sprintf(p.expr, quoted), p.nameBy, window, step)
if err != nil {
if log != nil {
log.Warn("could not read a node's history", "node", node, "err", err)
}
out.Err = err.Error()
return out
}
p.into(out, l.LineChart(series, p.unit, 4))
}
// Every card its own line, up to the eight a node commonly has.
if gpus > 0 {
for _, p := range gpuPanels {
series, err := h.RangeQuery(ctx, fmt.Sprintf(p.expr, quoted), "gpu", window, step)
if err != nil {
continue
}
for i := range series {
series[i].Name = "GPU " + series[i].Name
}
p.into(out, l.LineChart(series, p.unit, max(gpus, 8)))
}
}
// The engines' queues, for the replicas that are here now.
if len(replicas) > 0 {
re := make([]string, len(replicas))
for i, r := range replicas {
re[i] = regexpQuote(r)
}
expr := fmt.Sprintf(`llmgw_endpoint_waiting{replica=~%q}`, strings.Join(re, "|"))
if series, err := h.RangeQuery(ctx, expr, "replica", window, step); err == nil {
out.Queue = l.LineChart(series, "", 4)
}
}
return out
}
// regexpQuote escapes what a replica ID could carry into a PromQL regex.
// IDs are letters, digits and dashes; this is for the day one is not.
func regexpQuote(s string) string {
var b strings.Builder
for _, r := range s {
if strings.ContainsRune(`\.+*?()|[]{}^$`, r) {
b.WriteRune('\\')
}
b.WriteRune(r)
}
return b.String()
}