fix(webui): repair broken scenario Run button onclick, dedupe build.sh overlay staging
- page_scenario.go: onclick built via JSON.stringify() embedded raw double
quotes inside a double-quoted HTML attribute, truncating the attribute so
the click handler never compiled; pass the name through an escaped
data-scenario-name attribute instead.
- build.sh: overlay staging rsyncs (OVERLAY_DIR->stage, stage->includes.chroot)
ran without --delete, so a scenario removed from the repo (a9924b0) stayed
baked into every ISO built from the persistent stage cache since — the
"second script" in the Scenario page's list.
- blackbox: rewritten around a deterministic local zip + incremental
patch-the-changed-suffix onto removable media, instead of walking/copying
~90 files through a synchronous ntfs-3g FUSE mount every cycle. journalctl
captures are now "--since last sync" (were "--since boot", growing with
uptime) and metrics.db is excluded (was copied whole every cycle).
- scenario: nvbandwidth-acs-ab now escalates GPU count (same-socket pair,
other socket's pair, one cross-socket pair, all GPUs) under each ACS state
instead of always running all 6 GPUs at once, using a new `bee
gpu-bandwidth-groups` subcommand that discovers socket layout from
`nvidia-smi topo -m` at runtime — gpu_indices is host-specific, so this
can't be baked into the scenario file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// nvidiaCPUAffinityRe matches an nvidia-smi "topo -m" CPU Affinity cell, e.g.
|
||||
// "0-95,192-287" or a plain "0". Matched by shape rather than column
|
||||
// position: NIC count (and therefore column offsets) varies per host, but
|
||||
// this is the first token after the GPU/NIC relation cells (X/PIX/NODE/SYS)
|
||||
// that looks like a core range list, on every layout seen so far.
|
||||
var nvidiaCPUAffinityRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?(,[0-9]+(-[0-9]+)?)*$`)
|
||||
|
||||
// NvidiaSocketGroup is every GPU index sharing one CPU Affinity range in an
|
||||
// "nvidia-smi topo -m" matrix — a proxy for "these GPUs are on the same CPU
|
||||
// socket/NUMA node".
|
||||
type NvidiaSocketGroup struct {
|
||||
CPUAffinity string
|
||||
GPUIndices []int
|
||||
}
|
||||
|
||||
// ParseNvidiaSocketGroups groups GPU indices from an "nvidia-smi topo -m"
|
||||
// matrix by CPU Affinity, so a scenario can pick "a pair on one socket,
|
||||
// then the other, then one cross-socket pair" without gpu_indices hardcoded
|
||||
// per host — topology (which GPUs share a socket) differs machine to
|
||||
// machine, so a scenario file can't bake this in the way it can bake in "run
|
||||
// on all GPUs".
|
||||
func ParseNvidiaSocketGroups(raw string) []NvidiaSocketGroup {
|
||||
lines := strings.Split(nvidiaNVLinkANSIRe.ReplaceAllString(raw, ""), "\n")
|
||||
headerIdx := -1
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "GPU0") {
|
||||
headerIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if headerIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
order := map[string]int{}
|
||||
groups := map[string][]int{}
|
||||
for _, line := range lines[headerIdx+1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "GPU") {
|
||||
continue
|
||||
}
|
||||
cells := strings.Fields(trimmed)
|
||||
if len(cells) < 2 {
|
||||
continue
|
||||
}
|
||||
rowGPU, err := strconv.Atoi(strings.TrimPrefix(cells[0], "GPU"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
affinity := ""
|
||||
for _, cell := range cells[1:] {
|
||||
if nvidiaCPUAffinityRe.MatchString(cell) {
|
||||
affinity = cell
|
||||
break
|
||||
}
|
||||
}
|
||||
if affinity == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := order[affinity]; !ok {
|
||||
order[affinity] = len(order)
|
||||
}
|
||||
groups[affinity] = append(groups[affinity], rowGPU)
|
||||
}
|
||||
|
||||
out := make([]NvidiaSocketGroup, 0, len(groups))
|
||||
for affinity, indices := range groups {
|
||||
sort.Ints(indices)
|
||||
out = append(out, NvidiaSocketGroup{CPUAffinity: affinity, GPUIndices: indices})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].GPUIndices[0] < out[j].GPUIndices[0]
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// NvidiaBandwidthTestGroup is one stage of a progressive multi-GPU bandwidth
|
||||
// test: a label and the GPU indices to run it against.
|
||||
type NvidiaBandwidthTestGroup struct {
|
||||
Label string
|
||||
GPUIndices []int
|
||||
}
|
||||
|
||||
// NvidiaProgressiveBandwidthGroups turns socket groups into an escalating
|
||||
// test plan: a pair within each socket that has one, then one cross-socket
|
||||
// pair (first two sockets' lowest-indexed GPU each), then every GPU. Lets a
|
||||
// scenario narrow down whether a failure needs the full GPU count or already
|
||||
// reproduces on a single cross-socket pair, instead of only ever testing
|
||||
// "all GPUs at once".
|
||||
func NvidiaProgressiveBandwidthGroups(socketGroups []NvidiaSocketGroup) []NvidiaBandwidthTestGroup {
|
||||
var out []NvidiaBandwidthTestGroup
|
||||
var allGPUs []int
|
||||
var crossSocketPair []int
|
||||
|
||||
for i, sg := range socketGroups {
|
||||
allGPUs = append(allGPUs, sg.GPUIndices...)
|
||||
if len(sg.GPUIndices) >= 2 {
|
||||
out = append(out, NvidiaBandwidthTestGroup{
|
||||
Label: "same-socket-" + strconv.Itoa(i+1),
|
||||
GPUIndices: []int{sg.GPUIndices[0], sg.GPUIndices[1]},
|
||||
})
|
||||
}
|
||||
if len(crossSocketPair) < 2 {
|
||||
crossSocketPair = append(crossSocketPair, sg.GPUIndices[0])
|
||||
}
|
||||
}
|
||||
if len(crossSocketPair) == 2 {
|
||||
out = append(out, NvidiaBandwidthTestGroup{Label: "cross-socket", GPUIndices: crossSocketPair})
|
||||
}
|
||||
if len(allGPUs) > 0 {
|
||||
sort.Ints(allGPUs)
|
||||
out = append(out, NvidiaBandwidthTestGroup{Label: "all", GPUIndices: allGPUs})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// realTopoTwoSocketSixGPU is a real "nvidia-smi topo -m" capture (CG480-S6053,
|
||||
// 6 GPUs across 2 sockets: GPU0-3 on one, GPU4-5 on the other), ANSI escapes
|
||||
// included as nvidia-smi actually emits them in the header row.
|
||||
const realTopoTwoSocketSixGPU = "\t\x1b[4mGPU0\tGPU1\tGPU2\tGPU3\tGPU4\tGPU5\tNIC0\tNIC1\tNIC2\tNIC3\tCPU Affinity\tNUMA Affinity\tGPU NUMA ID\x1b[0m\n" +
|
||||
"GPU0\t X \tPIX\tNODE\tNODE\tSYS\tSYS\tNODE\tNODE\tPIX\tPIX\t0-95,192-287\t0\t\tN/A\n" +
|
||||
"GPU1\tPIX\t X \tNODE\tNODE\tSYS\tSYS\tNODE\tNODE\tPIX\tPIX\t0-95,192-287\t0\t\tN/A\n" +
|
||||
"GPU2\tNODE\tNODE\t X \tPIX\tSYS\tSYS\tPIX\tPIX\tNODE\tNODE\t0-95,192-287\t0\t\tN/A\n" +
|
||||
"GPU3\tNODE\tNODE\tPIX\t X \tSYS\tSYS\tPIX\tPIX\tNODE\tNODE\t0-95,192-287\t0\t\tN/A\n" +
|
||||
"GPU4\tSYS\tSYS\tSYS\tSYS\t X \tPIX\tSYS\tSYS\tSYS\tSYS\t96-191,288-383\t1\t\tN/A\n" +
|
||||
"GPU5\tSYS\tSYS\tSYS\tSYS\tPIX\t X \tSYS\tSYS\tSYS\tSYS\t96-191,288-383\t1\t\tN/A\n"
|
||||
|
||||
// realTopoSingleSocketFourGPU simulates a single-socket host (all GPUs share
|
||||
// one CPU Affinity range) — no cross-socket stage should be produced.
|
||||
const realTopoSingleSocketFourGPU = "GPU0\tGPU1\tGPU2\tGPU3\tCPU Affinity\tNUMA Affinity\n" +
|
||||
"GPU0\t X \tPIX\tNODE\tNODE\t0-31\t0\n" +
|
||||
"GPU1\tPIX\t X \tNODE\tNODE\t0-31\t0\n" +
|
||||
"GPU2\tNODE\tNODE\t X \tPIX\t0-31\t0\n" +
|
||||
"GPU3\tNODE\tNODE\tPIX\t X \t0-31\t0\n"
|
||||
|
||||
func TestParseNvidiaSocketGroupsTwoSockets(t *testing.T) {
|
||||
groups := ParseNvidiaSocketGroups(realTopoTwoSocketSixGPU)
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("groups=%+v, want 2", groups)
|
||||
}
|
||||
if !reflect.DeepEqual(groups[0].GPUIndices, []int{0, 1, 2, 3}) {
|
||||
t.Fatalf("group0=%v, want [0 1 2 3]", groups[0].GPUIndices)
|
||||
}
|
||||
if !reflect.DeepEqual(groups[1].GPUIndices, []int{4, 5}) {
|
||||
t.Fatalf("group1=%v, want [4 5]", groups[1].GPUIndices)
|
||||
}
|
||||
if groups[0].CPUAffinity == groups[1].CPUAffinity {
|
||||
t.Fatalf("expected distinct CPU affinities, got %q for both", groups[0].CPUAffinity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNvidiaProgressiveBandwidthGroupsTwoSockets(t *testing.T) {
|
||||
socketGroups := ParseNvidiaSocketGroups(realTopoTwoSocketSixGPU)
|
||||
stages := NvidiaProgressiveBandwidthGroups(socketGroups)
|
||||
|
||||
want := []NvidiaBandwidthTestGroup{
|
||||
{Label: "same-socket-1", GPUIndices: []int{0, 1}},
|
||||
{Label: "same-socket-2", GPUIndices: []int{4, 5}},
|
||||
{Label: "cross-socket", GPUIndices: []int{0, 4}},
|
||||
{Label: "all", GPUIndices: []int{0, 1, 2, 3, 4, 5}},
|
||||
}
|
||||
if len(stages) != len(want) {
|
||||
t.Fatalf("stages=%+v, want %+v", stages, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if stages[i].Label != w.Label || !reflect.DeepEqual(stages[i].GPUIndices, w.GPUIndices) {
|
||||
t.Fatalf("stage %d = %+v, want %+v", i, stages[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNvidiaProgressiveBandwidthGroupsSingleSocket(t *testing.T) {
|
||||
socketGroups := ParseNvidiaSocketGroups(realTopoSingleSocketFourGPU)
|
||||
stages := NvidiaProgressiveBandwidthGroups(socketGroups)
|
||||
|
||||
want := []NvidiaBandwidthTestGroup{
|
||||
{Label: "same-socket-1", GPUIndices: []int{0, 1}},
|
||||
{Label: "all", GPUIndices: []int{0, 1, 2, 3}},
|
||||
}
|
||||
if len(stages) != len(want) {
|
||||
t.Fatalf("stages=%+v, want %+v (no cross-socket stage on a single-socket host)", stages, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if stages[i].Label != w.Label || !reflect.DeepEqual(stages[i].GPUIndices, w.GPUIndices) {
|
||||
t.Fatalf("stage %d = %+v, want %+v", i, stages[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNvidiaSocketGroupsEmptyOnGarbage(t *testing.T) {
|
||||
if got := ParseNvidiaSocketGroups("not a topology matrix"); len(got) != 0 {
|
||||
t.Fatalf("got %+v, want empty", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user