diff --git a/.gitignore b/.gitignore index 0a3c299c..de25c27e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ z-scratch/ # Binary files benchkit/cmd/benchmark/benchmark +benchkit/cmd/sweep/sweep examples/storage/storage # Gorums generated backup files @@ -37,6 +38,13 @@ cmd/protoc-gen-gorums/gengorums/template_static.go.bak go.work.sum branch-compare-* tmp/* +out/* .claude/settings.local.json .claude/agent-memory/ CLAUDE.md +scripts/bbchain-echo.sh +scripts/bbchain-ssh-config +scripts/bbchain-infocmp.sh +scripts/__pycache__/plot.cpython-314.pyc +scripts/install-bbchain-key.sh +report/dedup-eval*/* diff --git a/AGENTS.md b/AGENTS.md index da37b40e..3ebbbfb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ gorums/ │ ├── proto/ # .proto sources for the benchkit module │ ├── benchmark/ # Gorums workloads built on benchkit │ ├── cmd/benchmark/ # Benchmark node binary +│ └── cmd/sweep/ # Cluster sweep orchestrator ├── examples/ # Separate module: example implementations ├── internal/ # Internal packages ├── doc/ # Documentation @@ -187,6 +188,9 @@ make -B # Install protoc-gen-gorums plugin make installgorums +# Build benchmark tool +make benchmark + # Install required tools make tools ``` @@ -259,6 +263,7 @@ Before making significant changes, consult: - Gorums is used in performance-critical distributed systems - Benchmarking tools are available in `benchkit/benchmark/` and `benchkit/cmd/benchmark/` +- See `doc/benchmarking.md` for benchmarking procedures - Profile before optimizing - use Go's pprof tools ## Communication with Project Maintainer diff --git a/Makefile b/Makefile index 217083b5..29a628a3 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ runtime_deps := internal/stream/stream.pb.go internal/stream/stream_grpc.pb.go benchkit_deps := benchkit/benchkit.pb.go benchkit/control.pb.go benchkit/control_gorums.pb.go benchmark_deps := $(benchkit_deps) benchkit/benchmark/benchmark.pb.go benchkit/benchmark/benchmark_gorums.pb.go -.PHONY: all dev tools bootstrapgorums installgorums benchmark benchkit test compiletests genproto benchtest bench lint deadcode modernize goplscheck +.PHONY: all dev tools bootstrapgorums installgorums benchmark sweep test compiletests genproto benchtest bench lint deadcode modernize goplscheck all: dev benchmark compiletests @@ -36,6 +36,9 @@ benchmark: installgorums $(benchmark_deps) benchkit: installgorums $(benchkit_deps) +sweep: $(benchkit_deps) + @go build -C benchkit/cmd/sweep -o sweep . + # The benchkit module's generated code is written back into the module root # rather than next to its .proto file, so these cannot use the pattern rules. benchkit/benchkit.pb.go: $(bk_path)/benchkit/benchkit.proto @@ -171,7 +174,7 @@ goplscheck: exit 1; \ fi -# Regenerate all Gorums and protobuf generated files across the repo (dev, benchkit, benchmark, internal/tests, examples). +# Regenerate all Gorums and protobuf generated files across the repo (dev, benchmark, internal/tests, examples). # This will force regeneration even though the proto files have not changed. genproto: installgorums dev @echo "Regenerating all proto files (dev, benchkit, benchmark, internal/tests, examples)" diff --git a/benchkit/cmd/sweep/aggregate.go b/benchkit/cmd/sweep/aggregate.go new file mode 100644 index 00000000..91594069 --- /dev/null +++ b/benchkit/cmd/sweep/aggregate.go @@ -0,0 +1,472 @@ +package main + +import ( + "fmt" + "maps" + "math" + "slices" + "strconv" + "strings" + + "github.com/relab/gorums/benchkit" + "golang.org/x/exp/stats" +) + +// aggStat is one metric summarized across the repetitions of a configuration: +// the mean, the sample standard deviation, and the 95% confidence interval +// half-width of the mean (tCritical95(n-1)·sd/√n). n is the number of +// repetitions folded in; n == 0 marks a metric absent from every repetition +// (e.g. latency on a run that recorded none), which the CSV writer emits as +// an empty field. +type aggStat struct { + mean float64 + sd float64 + ci95 float64 + n int +} + +// meanSDCI summarizes xs. With fewer than two samples the spread is zero; an +// empty slice yields the zero aggStat (n == 0) rather than dividing by zero. +func meanSDCI(xs []float64) aggStat { + if len(xs) == 0 { + return aggStat{} + } + mean, sd := stats.MeanAndStdDev(xs) // sd == 0 for a single sample + ci95 := 0.0 + if len(xs) >= 2 { + ci95 = tCritical95(len(xs)-1) * sd / math.Sqrt(float64(len(xs))) + } + return aggStat{mean: mean, sd: sd, ci95: ci95, n: len(xs)} +} + +// tCritical95Table holds the two-tailed 95% Student's t critical value for +// degrees of freedom 1..30 (index 0 unused). Sweep repetition counts are +// typically in this range, where the t distribution's heavier tails matter: +// at df=2 (3 reps) the true multiplier is 4.30, more than double the normal +// distribution's 1.96 that a rep count this small does not justify. +var tCritical95Table = [...]float64{ + 0, // unused + 12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228, + 2.201, 2.179, 2.160, 2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086, + 2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042, +} + +// tCritical95 returns the two-tailed 95% Student's t critical value for the +// given degrees of freedom. Beyond the table it uses the asymptotic expansion +// around the normal critical value, avoiding an abrupt underestimate at df=31. +func tCritical95(df int) float64 { + if df >= 1 && df < len(tCritical95Table) { + return tCritical95Table[df] + } + + const normalCritical95 = 1.959963984540054 + if df <= 0 { + return normalCritical95 + } + v := float64(df) + z := normalCritical95 + z2 := z * z + z3 := z2 * z + z5 := z3 * z2 + z7 := z5 * z2 + return z + + (z3+z)/(4*v) + + (5*z5+16*z3+3*z)/(96*v*v) + + (3*z7+19*z5+17*z3-15*z)/(384*v*v*v) +} + +// aggRunRecord is the rep-averaged reduction of the per-rep rows in runs.csv: +// one row per configuration with each metric's mean, spread, and repetition +// counts. It is the primary tidy-long table the generated Typst figures read. +type aggRunRecord struct { + benchkit.Dimensions + reps int // repetitions folded into the means + repsDegraded int // repetitions flagged degraded for this configuration + throughput aggStat + allocsPerOp aggStat + memPerOp aggStat + meanUS aggStat + p50US aggStat + p95US aggStat + p99US aggStat +} + +// aggregateReps folds the per-rep records of each configuration into one +// rep-averaged record. Degraded repetitions are counted in repsDegraded and, +// unless includeDegraded is set, excluded from the means (their contaminated +// aggregates would otherwise bias the configuration). A configuration with no +// surviving repetitions is dropped. Records are returned sorted by +// benchmark, nodes, workers, payload, rate, buffer sizes, then stream mode. +func aggregateReps(runs []plotRunRecord, includeDegraded bool) []aggRunRecord { + type bucket struct { + thr, allocs, mem []float64 + meanUS, p50, p95, p99 []float64 + degraded int + } + buckets := make(map[benchkit.Dimensions]*bucket) + var order []benchkit.Dimensions + for _, r := range runs { + key := r.Dimensions + b := buckets[key] + if b == nil { + b = &bucket{} + buckets[key] = b + order = append(order, key) + } + if r.status == runStatusDegraded { + b.degraded++ + if !includeDegraded { + continue + } + } + b.thr = append(b.thr, r.throughput) + b.allocs = append(b.allocs, r.allocsPerOp) + b.mem = append(b.mem, r.memPerOp) + if r.meanUS != nil { + b.meanUS = append(b.meanUS, *r.meanUS) + } + if r.p50US != nil { + b.p50 = append(b.p50, *r.p50US) + } + if r.p95US != nil { + b.p95 = append(b.p95, *r.p95US) + } + if r.p99US != nil { + b.p99 = append(b.p99, *r.p99US) + } + } + slices.SortFunc(order, compareDimensions) + out := make([]aggRunRecord, 0, len(order)) + for _, key := range order { + b := buckets[key] + if len(b.thr) == 0 { + continue // only degraded reps, excluded + } + out = append(out, aggRunRecord{ + Dimensions: key, + reps: len(b.thr), repsDegraded: b.degraded, + throughput: meanSDCI(b.thr), + allocsPerOp: meanSDCI(b.allocs), + memPerOp: meanSDCI(b.mem), + meanUS: meanSDCI(b.meanUS), + p50US: meanSDCI(b.p50), + p95US: meanSDCI(b.p95), + p99US: meanSDCI(b.p99), + }) + } + return out +} + +// repOutlierSpread is how far a repetition's throughput may differ from its +// configuration's median, in either direction, before the report names it. Real +// repetitions of a healthy configuration cluster far more tightly than this; +// anything beyond it is a measurement to explain, not a data point to average. +const repOutlierSpread = 1.4 + +// repOutliers describes every repetition whose throughput differs from its +// configuration's median by more than spread in either direction, in run-base +// order. It is the report's defense in depth behind the sweep's own per-node +// bounds (see degraded.go): a directory collected before those bounds existed, +// or with them disabled, still gets its contaminated repetitions named rather +// than silently averaged in. Repetitions already flagged degraded are left out, +// since they are reported as such, and a configuration with fewer than three +// repetitions is skipped, because with two neither one is the outlier. +func repOutliers(runs []plotRunRecord, spread float64) []string { + if spread <= 1 { + return nil + } + byConfig := map[benchkit.Dimensions][]plotRunRecord{} + for _, r := range runs { + if r.status == runStatusDegraded { + continue + } + byConfig[r.Dimensions] = append(byConfig[r.Dimensions], r) + } + var notes []string + for _, reps := range byConfig { + if len(reps) < 3 { + continue + } + throughputs := make([]float64, len(reps)) + for i, r := range reps { + throughputs[i] = r.throughput + } + median := stats.Median(slices.Sorted(slices.Values(throughputs))) + if median <= 0 { + continue + } + for _, r := range reps { + relative := r.throughput / median + if relative > spread || relative < 1/spread { + notes = append(notes, fmt.Sprintf( + "run %s: %.0f ops/s is %.2fx the median of its %d repetitions", + r.base, r.throughput, relative, len(reps))) + } + } + } + slices.Sort(notes) + return notes +} + +// writeAggRunsCSV writes the rep-averaged tidy-long table. Each metric +// contributes value/_sd/_ci95 columns; latency percentiles additionally get +// millisecond mirrors. An absent metric (n == 0) is written as empty fields. +func writeAggRunsCSV(path string, rows []aggRunRecord) error { + header := append(dimensionColumns(), + []string{ + "reps", "reps_degraded", + "throughput", "throughput_sd", "throughput_ci95", + "goodput", "goodput_sd", "goodput_ci95", + "allocs_per_op", "allocs_per_op_sd", "allocs_per_op_ci95", + "mem_per_op", "mem_per_op_sd", "mem_per_op_ci95", + "mean_us", "mean_us_sd", "mean_us_ci95", + "p50_us", "p50_us_sd", "p50_us_ci95", + "p95_us", "p95_us_sd", "p95_us_ci95", + "p99_us", "p99_us_sd", "p99_us_ci95", + "p50_ms", "p50_ms_sd", "p50_ms_ci95", + "p95_ms", "p95_ms_sd", "p95_ms_ci95", + "p99_ms", "p99_ms_sd", "p99_ms_ci95", + }...) + return writeCSV(path, header, rows, func(r aggRunRecord) []string { + rec := append(dimensionValues(r.Dimensions), + []string{ + strconv.Itoa(r.reps), strconv.Itoa(r.repsDegraded), + }...) + rec = append(rec, statCols(r.throughput, 1)...) + rec = append(rec, statCols(goodputStat(r), 1)...) + rec = append(rec, statCols(r.allocsPerOp, 1)...) + rec = append(rec, statCols(r.memPerOp, 1)...) + rec = append(rec, statCols(r.meanUS, 1)...) + rec = append(rec, statCols(r.p50US, 1)...) + rec = append(rec, statCols(r.p95US, 1)...) + rec = append(rec, statCols(r.p99US, 1)...) + rec = append(rec, statCols(r.p50US, 1.0/1e3)...) + rec = append(rec, statCols(r.p95US, 1.0/1e3)...) + rec = append(rec, statCols(r.p99US, 1.0/1e3)...) + return rec + }) +} + +// comparisonMetrics are the metrics pivoted side by side in the wide +// comparison table. Each carries the scale from its stored aggStat unit +// (throughput as-is; latency percentiles µs→ms) to the emitted column. +var comparisonMetrics = []struct { + name string + get func(aggRunRecord) aggStat + scale float64 +}{ + {"throughput", func(r aggRunRecord) aggStat { return r.throughput }, 1}, + {"p50_ms", func(r aggRunRecord) aggStat { return r.p50US }, 1.0 / 1e3}, + {"p95_ms", func(r aggRunRecord) aggStat { return r.p95US }, 1.0 / 1e3}, + {"p99_ms", func(r aggRunRecord) aggStat { return r.p99US }, 1.0 / 1e3}, +} + +// comparisonRecord holds one configuration's rep-averaged records for each +// stream mode present, so the wide table can place the modes side by side and +// derive their ratios. +type comparisonRecord struct { + benchkit.Dimensions + baseline string + perMode map[string]aggRunRecord +} + +// pivotComparison groups the rep-averaged records by configuration (ignoring +// stream mode) so each mode's metrics can be compared side by side. It returns +// nil unless at least two stream modes are present in the data — the wide +// comparison table exists only for a mode-vs-mode study. baseline names the +// denominator mode for ratios; when empty or absent it defaults to "dual" if +// present, else the lexically first mode. +func pivotComparison(agg []aggRunRecord, baseline string) []comparisonRecord { + modes := make(map[string]bool) + for _, r := range agg { + modes[r.StreamMode] = true + } + if len(modes) < 2 { + return nil + } + if !modes[baseline] { + if modes["dual"] { + baseline = "dual" + } else { + baseline = slices.Min(slices.Collect(maps.Keys(modes))) + } + } + + byConfig := make(map[benchkit.Dimensions]*comparisonRecord) + var order []benchkit.Dimensions + for _, r := range agg { + key := comparisonDimensions(r.Dimensions) + c := byConfig[key] + if c == nil { + c = &comparisonRecord{ + Dimensions: key, baseline: baseline, + perMode: make(map[string]aggRunRecord), + } + byConfig[key] = c + order = append(order, key) + } + c.perMode[r.StreamMode] = r + } + slices.SortFunc(order, compareDimensions) + out := make([]comparisonRecord, 0, len(order)) + for _, key := range order { + out = append(out, *byConfig[key]) + } + return out +} + +// ratioStat divides metric x by baseline y, propagating relative uncertainty: +// sd_r = |r|·√((sd_x/x)² + (sd_y/y)²). It reports false when either metric is +// absent or the baseline mean is zero. +func ratioStat(x, y aggStat) (aggStat, bool) { + if x.n == 0 || y.n == 0 || y.mean == 0 { + return aggStat{}, false + } + r := x.mean / y.mean + var rel float64 + if x.mean != 0 { + rel += (x.sd / x.mean) * (x.sd / x.mean) + } + rel += (y.sd / y.mean) * (y.sd / y.mean) + return aggStat{mean: r, sd: math.Abs(r) * math.Sqrt(rel), n: min(x.n, y.n)}, true +} + +// writeComparisonCSV writes the wide comparison table: per configuration, each +// metric's value/_sd for every stream mode present across the data, followed +// by the non-baseline/baseline ratio (and its propagated _sd) for the metrics +// of configurations that hold exactly the baseline plus one other mode. +func writeComparisonCSV(path string, rows []comparisonRecord) error { + modeSet := make(map[string]bool) + for _, r := range rows { + for m := range r.perMode { + modeSet[m] = true + } + } + allModes := slices.Sorted(maps.Keys(modeSet)) + + header := append(dimensionColumns("stream_mode"), "baseline", "modes") + for _, m := range comparisonMetrics { + for _, mode := range allModes { + header = append(header, m.name+"_"+mode, m.name+"_"+mode+"_sd") + } + } + for _, m := range comparisonMetrics { + header = append(header, m.name+"_ratio", m.name+"_ratio_sd") + } + + return writeCSV(path, header, rows, func(r comparisonRecord) []string { + present := slices.Sorted(maps.Keys(r.perMode)) + rec := append(dimensionValues(r.Dimensions, "stream_mode"), r.baseline, strings.Join(present, "|")) + for _, m := range comparisonMetrics { + for _, mode := range allModes { + rr, ok := r.perMode[mode] + if !ok { + rec = append(rec, "", "") + continue + } + s := m.get(rr) + if s.n == 0 { + rec = append(rec, "", "") + continue + } + rec = append(rec, formatFloat(s.mean*m.scale), formatFloat(s.sd*m.scale)) + } + } + // Ratio is defined only when the baseline and exactly one other mode + // are present; more modes leave the comparison ambiguous. + other, ratioable := soleOtherMode(present, r.baseline) + for _, m := range comparisonMetrics { + if !ratioable { + rec = append(rec, "", "") + continue + } + ratio, ok := ratioStat(m.get(r.perMode[other]), m.get(r.perMode[r.baseline])) + if !ok { + rec = append(rec, "", "") + continue + } + rec = append(rec, formatFloat(ratio.mean), formatFloat(ratio.sd)) + } + return rec + }) +} + +// ratioAxisVaries reports whether the paired comparison rows can draw a ratio +// line for one metric against xcol. It mirrors what the ratio-vs figure draws: +// a series is a benchmark plus the dimensions held fixed within a panel (every +// dimension other than xcol and facet, which is empty for a single-panel +// figure), and a series needs a computable ratio at two or more distinct x +// values before anything but the parity line appears. A metric absent from +// either mode, or a configuration without exactly the baseline and one other +// mode, has no ratio and contributes no point. +func ratioAxisVaries(rows []comparisonRecord, metric func(aggRunRecord) aggStat, xcol, facet string) bool { + // The facet is part of the series identity: each panel holds its own lines. + seriesDims := append([]string{facet}, slices.DeleteFunc(slices.Clone(dimOrder), func(d string) bool { + return d == xcol || d == facet + })...) + xvalues := map[string]map[string]bool{} + for _, r := range rows { + other, ok := soleOtherMode(slices.Sorted(maps.Keys(r.perMode)), r.baseline) + if !ok { + continue + } + if _, ok := ratioStat(metric(r.perMode[other]), metric(r.perMode[r.baseline])); !ok { + continue + } + parts := make([]string, 0, len(seriesDims)+1) + parts = append(parts, r.Benchmark) + for _, d := range seriesDims { + parts = append(parts, dimensionValue(r.Dimensions, d)) + } + key := strings.Join(parts, "|") + if xvalues[key] == nil { + xvalues[key] = map[string]bool{} + } + xvalues[key][dimensionValue(r.Dimensions, xcol)] = true + if len(xvalues[key]) > 1 { + return true + } + } + return false +} + +// soleOtherMode returns the single non-baseline mode when present holds exactly +// the baseline and one other mode; otherwise it reports false. +func soleOtherMode(present []string, baseline string) (string, bool) { + if len(present) != 2 || !slices.Contains(present, baseline) { + return "", false + } + for _, m := range present { + if m != baseline { + return m, true + } + } + return "", false +} + +// goodputStat derives cluster byte throughput (throughput × payload) for one +// configuration. The spread scales linearly with the constant payload, so the +// sample sd and CI carry over multiplied by the payload. +func goodputStat(r aggRunRecord) aggStat { + p := float64(r.Payload) + return aggStat{ + mean: r.throughput.mean * p, + sd: r.throughput.sd * p, + ci95: r.throughput.ci95 * p, + n: r.throughput.n, + } +} + +// statCols renders one aggStat as its value/_sd/_ci95 fields, scaling every +// component by scale (e.g. 1/1000 to convert µs to ms). An absent metric +// (n == 0) becomes three empty fields. +func statCols(s aggStat, scale float64) []string { + if s.n == 0 { + return []string{"", "", ""} + } + return []string{ + formatFloat(s.mean * scale), + formatFloat(s.sd * scale), + formatFloat(s.ci95 * scale), + } +} diff --git a/benchkit/cmd/sweep/aggregate_test.go b/benchkit/cmd/sweep/aggregate_test.go new file mode 100644 index 00000000..7d0fb9b1 --- /dev/null +++ b/benchkit/cmd/sweep/aggregate_test.go @@ -0,0 +1,421 @@ +package main + +import ( + "encoding/csv" + "math" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/relab/gorums/benchkit" +) + +// qRun builds a per-rep plot record for a fixed Q/N3/W?/P0 configuration; the +// same latency value stands in for every percentile. +func qRun(workers int, mode, status string, thr, lat float64) plotRunRecord { + return plotRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: workers, StreamMode: mode, + }, + status: status, throughput: thr, + allocsPerOp: 1, memPerOp: 100, + p50US: new(lat), p95US: new(lat), p99US: new(lat), meanUS: new(lat), + } +} + +// TestAggregateRepsSeparatesBufferSizes verifies that runs differing only by a +// buffer capacity aggregate into separate rows, and that the capacities reach +// the CSV. Folding them together would report one blended row per +// configuration and silently average across the setting under test. +func TestAggregateRepsSeparatesBufferSizes(t *testing.T) { + bufRun := func(sendBuffer, recvBuffer int, thr float64) plotRunRecord { + r := qRun(1, "dual", runStatusSucceeded, thr, 10.0) + r.SendBuffer, r.RecvBuffer = sendBuffer, recvBuffer + return r + } + runs := []plotRunRecord{ + bufRun(64, 0, 100), bufRun(64, 0, 120), + bufRun(4096, 0, 300), + bufRun(64, 16, 200), + } + agg := aggregateReps(runs, false) + if len(agg) != 3 { + t.Fatalf("aggregated to %d rows, want 3 (one per distinct buffer pair)", len(agg)) + } + got := map[[2]int]float64{} + for _, r := range agg { + got[[2]int{r.SendBuffer, r.RecvBuffer}] = r.throughput.mean + } + want := map[[2]int]float64{{64, 0}: 110, {4096, 0}: 300, {64, 16}: 200} + for k, w := range want { + if g, ok := got[k]; !ok { + t.Errorf("missing row for send=%d recv=%d", k[0], k[1]) + } else if math.Abs(g-w) > 1e-9 { + t.Errorf("send=%d recv=%d throughput = %v, want %v", k[0], k[1], g, w) + } + } + + path := filepath.Join(t.TempDir(), "agg.csv") + if err := writeAggRunsCSV(path, agg); err != nil { + t.Fatalf("writeAggRunsCSV: %v", err) + } + recs, err := csv.NewReader(mustOpen(t, path)).ReadAll() + if err != nil { + t.Fatalf("read agg.csv: %v", err) + } + sendCol := slices.Index(recs[0], "send_buffer") + recvCol := slices.Index(recs[0], "recv_buffer") + if sendCol < 0 || recvCol < 0 { + t.Fatalf("agg.csv header lacks buffer columns: %v", recs[0]) + } + seen := map[[2]string]bool{} + for _, rec := range recs[1:] { + seen[[2]string{rec[sendCol], rec[recvCol]}] = true + } + if len(seen) != 3 { + t.Errorf("agg.csv has %d distinct buffer pairs, want 3", len(seen)) + } +} + +// mustOpen opens path for reading and closes it when the test ends. +func mustOpen(t *testing.T, path string) *os.File { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open %s: %v", path, err) + } + t.Cleanup(func() { f.Close() }) + return f +} + +func TestAggregateReps(t *testing.T) { + runs := []plotRunRecord{ + qRun(2, "dual", runStatusSucceeded, 100, 10.0), + qRun(2, "dual", runStatusSucceeded, 200, 20.0), + qRun(2, "dual", runStatusDegraded, 999, 999.0), + qRun(4, "dual", runStatusSucceeded, 50, 5.0), + } + + t.Run("ExcludeDegraded", func(t *testing.T) { + got := aggregateReps(runs, false) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + // Sorted: (W2) before (W4). + w2 := got[0] + if w2.Workers != 2 { + t.Fatalf("got[0].workers = %d, want 2", w2.Workers) + } + if w2.reps != 2 || w2.repsDegraded != 1 { + t.Errorf("reps=%d repsDegraded=%d, want 2 and 1", w2.reps, w2.repsDegraded) + } + // mean(100,200)=150; sample sd=sqrt(5000)=70.7107; ci95=t(df=1)*sd/sqrt(2). + wantSD := math.Sqrt(5000) + wantCI := 12.706 * wantSD / math.Sqrt(2) + assertStat(t, "throughput", w2.throughput, aggStat{mean: 150, sd: wantSD, ci95: wantCI, n: 2}) + // p50 mean(10,20)=15. + assertStat(t, "p50US", w2.p50US, aggStat{mean: 15, sd: math.Sqrt(50), ci95: 12.706 * math.Sqrt(50) / math.Sqrt(2), n: 2}) + + w4 := got[1] + if w4.reps != 1 || w4.repsDegraded != 0 { + t.Errorf("W4 reps=%d repsDegraded=%d, want 1 and 0", w4.reps, w4.repsDegraded) + } + assertStat(t, "throughput", w4.throughput, aggStat{mean: 50, sd: 0, ci95: 0, n: 1}) + }) + + t.Run("IncludeDegraded", func(t *testing.T) { + got := aggregateReps(runs, true) + w2 := got[0] + if w2.reps != 3 || w2.repsDegraded != 1 { + t.Errorf("reps=%d repsDegraded=%d, want 3 and 1", w2.reps, w2.repsDegraded) + } + if math.Abs(w2.throughput.mean-433) > 0.5 { + t.Errorf("throughput mean = %g, want ~433", w2.throughput.mean) + } + }) +} + +func TestWriteAggRunsCSV(t *testing.T) { + rows := aggregateReps([]plotRunRecord{ + qRun(2, "dual", runStatusSucceeded, 100, 10.0), + qRun(2, "dual", runStatusSucceeded, 200, 20.0), + }, false) + path := filepath.Join(t.TempDir(), "agg.csv") + if err := writeAggRunsCSV(path, rows); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + recs, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 { + t.Fatalf("rows = %d (incl header), want 2", len(recs)) + } + header := recs[0] + col := func(name string) int { + for i, h := range header { + if h == name { + return i + } + } + t.Fatalf("column %q not in header %v", name, header) + return -1 + } + row := recs[1] + if row[col("benchmark")] != "Q" || row[col("workers")] != "2" { + t.Errorf("benchmark/workers = %q/%q", row[col("benchmark")], row[col("workers")]) + } + if row[col("throughput")] != "150" { + t.Errorf("throughput = %q, want 150", row[col("throughput")]) + } + if row[col("reps")] != "2" { + t.Errorf("reps = %q, want 2", row[col("reps")]) + } + // ms mirror of the us column: p50_us=15 -> p50_ms=0.015. + if row[col("p50_ms")] != "0.015" { + t.Errorf("p50_ms = %q, want 0.015", row[col("p50_ms")]) + } +} + +// TestRatioStat verifies ratioStat's propagated-uncertainty formula +// (sd_r = |r|*sqrt((sd_x/x)^2 + (sd_y/y)^2)) and its three "not comparable" +// cases: an absent metric on either side, and a zero baseline mean. +func TestRatioStat(t *testing.T) { + t.Run("PropagatesRelativeUncertainty", func(t *testing.T) { + x := aggStat{mean: 200, sd: 20, n: 3} + y := aggStat{mean: 100, sd: 10, n: 5} + got, ok := ratioStat(x, y) + if !ok { + t.Fatal("ok = false, want true") + } + if math.Abs(got.mean-2.0) > 1e-9 { + t.Errorf("mean = %v, want 2.0", got.mean) + } + // rel = (20/200)^2 + (10/100)^2 = 0.01 + 0.01 = 0.02; sd = 2*sqrt(0.02). + wantSD := 2.0 * math.Sqrt(0.02) + if math.Abs(got.sd-wantSD) > 1e-9 { + t.Errorf("sd = %v, want %v", got.sd, wantSD) + } + if got.n != 3 { + t.Errorf("n = %d, want min(3, 5) = 3", got.n) + } + }) + + t.Run("ZeroBaselineMeanIsNotComparable", func(t *testing.T) { + _, ok := ratioStat(aggStat{mean: 100, n: 3}, aggStat{mean: 0, n: 3}) + if ok { + t.Error("ok = true with a zero baseline mean, want false") + } + }) + + t.Run("AbsentXIsNotComparable", func(t *testing.T) { + _, ok := ratioStat(aggStat{n: 0}, aggStat{mean: 100, n: 3}) + if ok { + t.Error("ok = true with x.n == 0, want false") + } + }) + + t.Run("AbsentYIsNotComparable", func(t *testing.T) { + _, ok := ratioStat(aggStat{mean: 100, n: 3}, aggStat{n: 0}) + if ok { + t.Error("ok = true with y.n == 0, want false") + } + }) + + t.Run("ZeroXMeanSkipsItsOwnRelativeTerm", func(t *testing.T) { + // x.mean == 0 (but n > 0, unlike the AbsentX case) must not divide by + // zero computing its own relative term; only y's term contributes. + x := aggStat{mean: 0, sd: 5, n: 3} + y := aggStat{mean: 100, sd: 10, n: 3} + got, ok := ratioStat(x, y) + if !ok { + t.Fatal("ok = false, want true") + } + if got.mean != 0 { + t.Errorf("mean = %v, want 0", got.mean) + } + if got.sd != 0 { + t.Errorf("sd = %v, want 0 (|r|=0 zeroes the propagated sd regardless of rel)", got.sd) + } + }) +} + +func TestPivotComparison(t *testing.T) { + agg := aggregateReps([]plotRunRecord{ + qRun(2, "dual", runStatusSucceeded, 100, 10.0), + qRun(2, "dual", runStatusSucceeded, 100, 10.0), + qRun(2, "dedup", runStatusSucceeded, 150, 8.0), + qRun(2, "dedup", runStatusSucceeded, 150, 8.0), + }, false) + + t.Run("DefaultBaselineDual", func(t *testing.T) { + cmp := pivotComparison(agg, "") + if len(cmp) != 1 { + t.Fatalf("len = %d, want 1", len(cmp)) + } + c := cmp[0] + if c.baseline != "dual" { + t.Errorf("baseline = %q, want dual", c.baseline) + } + if len(c.perMode) != 2 { + t.Fatalf("perMode has %d modes, want 2", len(c.perMode)) + } + // dedup/dual throughput ratio = 150/100 = 1.5. + ratio, ok := ratioStat(c.perMode["dedup"].throughput, c.perMode["dual"].throughput) + if !ok || math.Abs(ratio.mean-1.5) > 1e-9 { + t.Errorf("throughput ratio = %+v ok=%v, want mean 1.5", ratio, ok) + } + }) + + t.Run("SingleModeYieldsNil", func(t *testing.T) { + one := aggregateReps([]plotRunRecord{ + qRun(2, "dual", runStatusSucceeded, 100, 10.0), + }, false) + if got := pivotComparison(one, ""); got != nil { + t.Errorf("pivotComparison with one mode = %v, want nil", got) + } + }) +} + +func TestPivotComparisonRetainsBufferDimensions(t *testing.T) { + var runs []plotRunRecord + for _, send := range []int{64, 256} { + for _, mode := range []string{"dual", "dedup"} { + r := qRun(2, mode, runStatusSucceeded, float64(send), 10) + r.SendBuffer = send + runs = append(runs, r) + } + } + rows := pivotComparison(aggregateReps(runs, false), "dual") + if len(rows) != 2 { + t.Fatalf("comparison rows = %d, want 2 buffer configurations", len(rows)) + } + if rows[0].SendBuffer == rows[1].SendBuffer { + t.Fatalf("buffer configurations merged: %+v", rows) + } + for _, row := range rows { + if len(row.perMode) != 2 { + t.Errorf("send_buffer=%d has %d modes, want 2", row.SendBuffer, len(row.perMode)) + } + } +} + +func TestWriteComparisonCSV(t *testing.T) { + agg := aggregateReps([]plotRunRecord{ + qRun(2, "dual", runStatusSucceeded, 100, 10.0), + qRun(2, "dual", runStatusSucceeded, 100, 10.0), + qRun(2, "dedup", runStatusSucceeded, 150, 8.0), + qRun(2, "dedup", runStatusSucceeded, 150, 8.0), + }, false) + rows := pivotComparison(agg, "") + path := filepath.Join(t.TempDir(), "comparison.csv") + if err := writeComparisonCSV(path, rows); err != nil { + t.Fatal(err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + recs, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 { + t.Fatalf("rows = %d (incl header), want 2", len(recs)) + } + header, row := recs[0], recs[1] + col := func(name string) int { + i := slices.Index(header, name) + if i < 0 { + t.Fatalf("column %q not in header %v", name, header) + } + return i + } + if row[col("throughput_dual")] != "100" || row[col("throughput_dedup")] != "150" { + t.Errorf("throughput dual/dedup = %q/%q", row[col("throughput_dual")], row[col("throughput_dedup")]) + } + if row[col("throughput_ratio")] != "1.5" { + t.Errorf("throughput_ratio = %q, want 1.5", row[col("throughput_ratio")]) + } + if row[col("modes")] != "dedup|dual" { + t.Errorf("modes = %q, want dedup|dual", row[col("modes")]) + } +} + +// TestTCritical95 verifies the two-tailed 95% Student's t critical value at +// typical sweep repetition counts (small df, where it diverges sharply from +// the normal distribution) and beyond the exact table, where the approximation +// must converge smoothly toward the normal critical value. +func TestTCritical95(t *testing.T) { + tests := []struct { + df int + want float64 + }{ + {1, 12.706}, // 2 reps + {2, 4.303}, // 3 reps + {4, 2.776}, // 5 reps + {30, 2.042}, + {31, 2.0395}, + {40, 2.0211}, + {100, 1.9840}, + {1000, 1.9623}, + } + for _, tt := range tests { + if got := tCritical95(tt.df); math.Abs(got-tt.want) > 0.0001 { + t.Errorf("tCritical95(%d) = %v, want %v", tt.df, got, tt.want) + } + } +} + +func assertStat(t *testing.T, name string, got, want aggStat) { + t.Helper() + const eps = 1e-6 + if math.Abs(got.mean-want.mean) > eps || math.Abs(got.sd-want.sd) > eps || + math.Abs(got.ci95-want.ci95) > eps || got.n != want.n { + t.Errorf("%s = %+v, want %+v", name, got, want) + } +} + +// TestRepOutliers verifies the report's cross-repetition check: a repetition far +// from its configuration's median is named in either direction — the run-over +// case was 14x above it, which a one-sided check misses — while healthy +// repetition scatter, configurations with too few repetitions, and repetitions +// already flagged degraded are left alone. +func TestRepOutliers(t *testing.T) { + dims := func(workers int) benchkit.Dimensions { + return benchkit.Dimensions{Benchmark: "Q", Nodes: 9, Workers: workers, StreamMode: "dedup"} + } + runs := []plotRunRecord{ + // Healthy scatter around 5000 across four reps. + {Dimensions: dims(8), base: "r1", status: runStatusSucceeded, throughput: 4800}, + {Dimensions: dims(8), base: "r2", status: runStatusSucceeded, throughput: 5000}, + {Dimensions: dims(8), base: "r3", status: runStatusSucceeded, throughput: 5200}, + // The run-over case: one rep far above its siblings, plus one far below. + {Dimensions: dims(16), base: "s1", status: runStatusSucceeded, throughput: 5000}, + {Dimensions: dims(16), base: "s2", status: runStatusSucceeded, throughput: 5100}, + {Dimensions: dims(16), base: "s3", status: runStatusSucceeded, throughput: 710000}, + {Dimensions: dims(16), base: "s4", status: runStatusSucceeded, throughput: 100}, + // Already reported as degraded; not named again. + {Dimensions: dims(16), base: "s5", status: runStatusDegraded, throughput: 12}, + // Two reps only: neither is the outlier. + {Dimensions: dims(32), base: "t1", status: runStatusSucceeded, throughput: 5000}, + {Dimensions: dims(32), base: "t2", status: runStatusSucceeded, throughput: 50000}, + } + notes := repOutliers(runs, repOutlierSpread) + if len(notes) != 2 { + t.Fatalf("notes = %v, want 2 (s3 above, s4 below)", notes) + } + for i, want := range []string{"run s3: 710000 ops/s is 140.59x", "run s4: 100 ops/s is 0.02x"} { + if !strings.HasPrefix(notes[i], want) { + t.Errorf("notes[%d] = %q, want prefix %q", i, notes[i], want) + } + } +} diff --git a/benchkit/cmd/sweep/collectflag.go b/benchkit/cmd/sweep/collectflag.go new file mode 100644 index 00000000..99b9becb --- /dev/null +++ b/benchkit/cmd/sweep/collectflag.go @@ -0,0 +1,40 @@ +package main + +import "strings" + +// optionalPathFlag behaves as a boolean flag when no value is supplied and as +// a path flag for both "-collect path" and "-collect=path". +type optionalPathFlag struct { + value *string +} + +func (f optionalPathFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +func (f optionalPathFlag) Set(value string) error { + if value == "true" { + value = latestRunSentinel + } + *f.value = value + return nil +} + +func (optionalPathFlag) IsBoolFlag() bool { return true } + +func normalizeOptionalPathArgs(args []string) []string { + out := append([]string(nil), args...) + for i := 0; i < len(out); i++ { + if out[i] != "-collect" && out[i] != "-collect-now" && out[i] != "--collect" && out[i] != "--collect-now" { + continue + } + if i+1 < len(out) && !strings.HasPrefix(out[i+1], "-") { + out[i] += "=" + out[i+1] + out = append(out[:i+1], out[i+2:]...) + } + } + return out +} diff --git a/benchkit/cmd/sweep/convert.go b/benchkit/cmd/sweep/convert.go new file mode 100644 index 00000000..66a830c3 --- /dev/null +++ b/benchkit/cmd/sweep/convert.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/relab/gorums/benchkit" + "google.golang.org/protobuf/encoding/protojson" +) + +// convertBinaryResults writes a human-readable protojson ".json" sibling next +// to each collected ".binpb" file for a run, for manual inspection of a result. +// The binary file is kept as the source-of-truth artifact. +// +// Missing or undecodable files are skipped with a warning so a partial run still +// converts whatever it collected. +func convertBinaryResults(outdir, base string, nodes []nodeAssignment) { + for _, node := range nodes { + binPath := filepath.Join(outdir, resultFilename(base, node, resultExt)) + if err := convertBinaryFile(binPath); err != nil { + log.Printf(" warning: convert: %v", err) + } + } +} + +// convertDirBinaryResults writes a protojson ".json" sibling for every ".binpb" +// result file in dir, returning the number successfully converted. The laptop +// driver calls this after downloading a driven run, which ships only the binary +// results to keep the WAN transfer small; the readable protojson is +// regenerated here instead of crossing the WAN. Undecodable files are skipped +// with a warning so a partial download still converts whatever it has. +func convertDirBinaryResults(dir string) (int, error) { + matches, err := filepath.Glob(filepath.Join(dir, "*"+resultExt)) + if err != nil { + return 0, err + } + n := 0 + for _, binPath := range matches { + if err := convertBinaryFile(binPath); err != nil { + log.Printf(" warning: convert: %v", err) + continue + } + n++ + } + return n, nil +} + +// convertBinaryFile decodes one ".binpb" result file and writes its protojson +// ".json" sibling. Decoding uses the same generated [benchkit.Report] type the +// benchmark wrote it with, so the protojson output matches what +// protojson.Marshal produces for that schema. +func convertBinaryFile(binPath string) error { + data, err := os.ReadFile(binPath) + if err != nil { + return err + } + res, err := benchkit.DecodeReport(data) + if err != nil { + return fmt.Errorf("decode %s: %w", filepath.Base(binPath), err) + } + jsonBytes, err := protojson.Marshal(res) + if err != nil { + return fmt.Errorf("marshal %s: %w", filepath.Base(binPath), err) + } + jsonPath := strings.TrimSuffix(binPath, resultExt) + ".json" + if err := os.WriteFile(jsonPath, jsonBytes, 0o644); err != nil { + return fmt.Errorf("write %s: %w", filepath.Base(jsonPath), err) + } + return nil +} diff --git a/benchkit/cmd/sweep/convert_test.go b/benchkit/cmd/sweep/convert_test.go new file mode 100644 index 00000000..c010b3c5 --- /dev/null +++ b/benchkit/cmd/sweep/convert_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/relab/gorums/benchkit" + "google.golang.org/protobuf/encoding/protojson" +) + +// TestConvertBinaryResults verifies that convertBinaryResults decodes a collected +// .binpb file and writes a protojson .json sibling carrying the key result fields +// (name, throughput, latencies). The binary fixture is built via the shared +// buildBinaryResultFile helper, exercising the generated decode + protojson +// marshal path. +func TestConvertBinaryResults(t *testing.T) { + wantName := "SymmetricQuorumCall" + wantThroughput := 12345.6 + wantLatencies := []int64{100, 200, 300} + + dir := t.TempDir() + base := "rate-test_SymmetricQuorumCall_N1_W1_P0" + node := nodeAssignment{host: "bb1", port: 9000} + + binPath := filepath.Join(dir, resultFilename(base, node, ".binpb")) + if err := os.WriteFile(binPath, buildBinaryResultFile(t, wantName, wantThroughput, wantLatencies), 0o644); err != nil { + t.Fatalf("write binary fixture: %v", err) + } + + convertBinaryResults(dir, base, []nodeAssignment{node}) + + jsonPath := filepath.Join(dir, resultFilename(base, node, ".json")) + data, err := os.ReadFile(jsonPath) + if err != nil { + t.Fatalf("read converted json: %v", err) + } + + var res benchkit.Report + if err := protojson.Unmarshal(data, &res); err != nil { + t.Fatalf("unmarshal converted json: %v", err) + } + if len(res.GetResults()) != 1 { + t.Fatalf("results count = %d, want 1", len(res.GetResults())) + } + r := res.GetResults()[0] + if r.GetConfig().GetName() != wantName { + t.Errorf("name = %q, want %q", r.GetConfig().GetName(), wantName) + } + if r.GetThroughput() != wantThroughput { + t.Errorf("throughput = %v, want %v", r.GetThroughput(), wantThroughput) + } + if got := r.GetLatencies(); len(got) != len(wantLatencies) { + t.Fatalf("latencies count = %d, want %d", len(got), len(wantLatencies)) + } else { + for i, lat := range wantLatencies { + if got[i] != lat { + t.Errorf("latencies[%d] = %d, want %d", i, got[i], lat) + } + } + } +} + +// TestConvertDirBinaryResults verifies that convertDirBinaryResults converts +// every .binpb in a directory to its .json sibling, returns the count of files +// converted, and skips undecodable files without failing the whole batch (so a +// partially downloaded driver run still converts whatever it has). +func TestConvertDirBinaryResults(t *testing.T) { + dir := t.TempDir() + base := "e1_SymmetricQuorumCall_N2_W1_P0" + good := []nodeAssignment{{host: "bb1", port: 9000}, {host: "bb2", port: 9000}} + for _, node := range good { + binPath := filepath.Join(dir, resultFilename(base, node, resultExt)) + if err := os.WriteFile(binPath, buildBinaryResultFile(t, "SymmetricQuorumCall", 1, []int64{1, 2}), 0o644); err != nil { + t.Fatalf("write fixture: %v", err) + } + } + // A .binpb that is not a benchkit file must be skipped, not counted. + badPath := filepath.Join(dir, resultFilename(base, nodeAssignment{host: "bb3", port: 9000}, resultExt)) + if err := os.WriteFile(badPath, []byte("not a benchkit file"), 0o644); err != nil { + t.Fatalf("write bad fixture: %v", err) + } + + n, err := convertDirBinaryResults(dir) + if err != nil { + t.Fatalf("convertDirBinaryResults: %v", err) + } + if n != len(good) { + t.Errorf("converted count = %d, want %d", n, len(good)) + } + for _, node := range good { + jsonPath := filepath.Join(dir, resultFilename(base, node, ".json")) + if _, err := os.Stat(jsonPath); err != nil { + t.Errorf("missing converted json for %s: %v", node.host, err) + } + } + badJSON := filepath.Join(dir, resultFilename(base, nodeAssignment{host: "bb3", port: 9000}, ".json")) + if _, err := os.Stat(badJSON); err == nil { + t.Errorf("undecodable file should not produce json: %s", badJSON) + } +} diff --git a/benchkit/cmd/sweep/csvio.go b/benchkit/cmd/sweep/csvio.go new file mode 100644 index 00000000..73dd848a --- /dev/null +++ b/benchkit/cmd/sweep/csvio.go @@ -0,0 +1,136 @@ +package main + +import ( + "encoding/csv" + "errors" + "io" + "log" + "os" + "path/filepath" + "time" +) + +const csvProgressThreshold = 100 << 20 + +// writeCSV writes a header and one record per row to path. +func writeCSV[T any](path string, header []string, rows []T, fields func(T) []string) (err error) { + f, err := os.Create(path) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, f.Close()) + }() + return writeCSVTo(f, header, rows, fields) +} + +// writeCSVTo writes a header and one record per row to w. +func writeCSVTo[T any](w io.Writer, header []string, rows []T, fields func(T) []string) error { + cw := csv.NewWriter(w) + if err := cw.Write(header); err != nil { + return err + } + for _, row := range rows { + if err := cw.Write(fields(row)); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} + +// forEachCSVRow streams the data records in path to visit. The header is +// exposed as a name-to-column map so readers tolerate reordered and additional +// columns. When progress is set, large inputs periodically report bytes read. +func forEachCSVRow(path string, progress bool, visit func([]string, map[string]int) error) (err error) { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, f.Close()) + }() + + var input io.Reader = f + var tracker *csvProgressReader + if progress { + info, statErr := f.Stat() + if statErr != nil { + return statErr + } + log.Printf(" report: reading %s (%s)...", path, formatSize(info.Size())) + if info.Size() >= csvProgressThreshold { + tracker = newCSVProgressReader(f, filepath.Base(path), info.Size()) + input = tracker + } + } + + cr := csv.NewReader(input) + cr.ReuseRecord = true + header, err := cr.Read() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + columns := columnIndex(header) + rows := 0 + for { + record, readErr := cr.Read() + if readErr == io.EOF { + break + } + if readErr != nil { + return readErr + } + if err := visit(record, columns); err != nil { + return err + } + rows++ + } + if progress { + if tracker != nil { + tracker.finish() + } + log.Printf(" report: read %d row(s) from %s", rows, path) + } + return nil +} + +type csvProgressReader struct { + reader io.Reader + name string + total int64 + read int64 + step int64 + next int64 + lastReport time.Time + reported int +} + +func newCSVProgressReader(reader io.Reader, name string, total int64) *csvProgressReader { + step := max(total/10, int64(64<<20)) + return &csvProgressReader{ + reader: reader, name: name, total: total, + step: step, next: step, lastReport: time.Now(), + } +} + +func (r *csvProgressReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + r.read += int64(n) + if r.read >= r.next && time.Since(r.lastReport) >= 2*time.Second { + r.reported = int(100 * r.read / r.total) + log.Printf(" report: reading %s: %d%%", r.name, r.reported) + r.next = r.read + r.step + r.lastReport = time.Now() + } + return n, err +} + +func (r *csvProgressReader) finish() { + if r.reported < 100 { + log.Printf(" report: reading %s: 100%%", r.name) + } +} diff --git a/benchkit/cmd/sweep/degraded.go b/benchkit/cmd/sweep/degraded.go new file mode 100644 index 00000000..16de9c2c --- /dev/null +++ b/benchkit/cmd/sweep/degraded.go @@ -0,0 +1,229 @@ +package main + +import ( + "cmp" + "fmt" + "maps" + "math" + "os" + "path/filepath" + "slices" + "time" + + "github.com/relab/gorums/benchkit" + "golang.org/x/exp/stats" +) + +// Degradation detection: a run whose nodes all completed can still be +// worthless when one node's measurement does not belong with its peers'. After +// each successful run the sweep compares every node against the run median in +// three ways and marks the run "degraded" when any of them trips: +// +// - Throughput far below the median (-degraded-below, default 0.5): the slow +// node — a lossy network link, a throttled CPU, a sick disk. It drags the +// cluster aggregate down and inflates tail latency without producing any +// error, so nothing else in the pipeline notices. +// - Throughput far above the median (-degraded-above, default 2): a node +// cannot complete several times its peers' work in a symmetric benchmark, +// so this is the signature of operations recorded without a network round +// trip. One such node inflated a config aggregate to 14x its median and +// destroyed the error bands of two figures before this check existed. +// - Median latency far below the run median (-degraded-latency-below, default +// 0.2): the same anomaly seen in the latency distribution, and independent +// evidence for it. A quorum call "completing" in a fraction of the time its +// peers need did not do the round trip the benchmark measures. +// +// Every bound is median-relative because healthy runs on real clusters already +// show ±30% node skew — an absolute or mean-relative bound would either flag +// everything or let a single extreme outlier drag the reference down with it. + +// Reasons a node was flagged, as recorded in the run manifest. +const ( + degradedSlow = "throughput below the run median" + degradedExcessThroughput = "throughput above the run median" + degradedFastLatency = "median latency below the run median" +) + +// degradedNode records one node whose measurement fell outside a degraded +// threshold, as stored in the run manifest. +type degradedNode struct { + Host string `json:"host"` // host:port label of the flagged node + Reason string `json:"reason,omitempty"` // which bound the node crossed + Throughput float64 `json:"throughput"` // ops/s over the kept window + Relative float64 `json:"relative_to_median"` // the flagged metric / the run median of that metric +} + +// String describes a flagged node for the sweep log. +func (d degradedNode) String() string { + return fmt.Sprintf("%s: %s, at %.0f%% of it (%.0f ops/s)", d.Host, d.Reason, 100*d.Relative, d.Throughput) +} + +// nodeMeasurement is one node's health signal over the kept window: the +// throughput it reported and its median latency in microseconds, which is 0 +// when the node recorded no latency at all. +type nodeMeasurement struct { + throughput float64 + p50US float64 +} + +// degradationBounds are the median-relative limits a node's measurement must +// respect. A non-positive bound disables that check. +type degradationBounds struct { + below float64 // minimum throughput, as a fraction of the run median + above float64 // maximum throughput, as a multiple of the run median + latencyBelow float64 // minimum median latency, as a fraction of the run median +} + +// enabled reports whether any bound is in force. +func (b degradationBounds) enabled() bool { + return b.below > 0 || b.above > 0 || b.latencyBelow > 0 +} + +// degradedBounds returns the degradation bounds the sweep flags configured. +func (cfg *config) degradedBounds() degradationBounds { + return degradationBounds{ + below: cfg.degradedBelow, + above: cfg.degradedAbove, + latencyBelow: cfg.degradedLatencyBelow, + } +} + +// collectNodeMeasurements loads each node's collected result file for a run and +// returns its health signal (throughput summed across the node's results and its +// median latency, both trimmed like the run summary) keyed by the node's host +// label. A run measures one benchmark, so the first result carrying latency data +// supplies the median. Missing or unreadable files are skipped without a +// warning: collection coverage is tracked by countResultFiles, and the caller +// runs only after a successful collection. +// +// When a run mixes client-measured and server-measured results (e.g. PBFT +// -client=primary: primary has client RTT thruput, backups have execute +// thruput), only the server-measured nodes are used for degradation if at +// least two exist — that isolates replica execute-lag health from the +// primary's client performance signal. If there are fewer than two +// server-measured nodes, all nodes are compared (legacy multi-client). +func collectNodeMeasurements(outdir, base string, nodes []nodeAssignment, trim time.Duration) map[string]nodeMeasurement { + type nodeEntry struct { + host string + measurement nodeMeasurement + server bool // true if any result is SERVER_MEASURED + client bool + } + // serverMeasuredOnly reports whether an entry carries a server-side + // measurement and no client-side one. + serverMeasuredOnly := func(n nodeEntry) bool { return n.server && !n.client } + var list []nodeEntry + for _, node := range nodes { + data, err := os.ReadFile(filepath.Join(outdir, resultFilename(base, node, resultExt))) + if err != nil { + continue + } + report, err := benchkit.DecodeReport(data) + if err != nil { + continue + } + entry := nodeEntry{host: node.hostAddr()} + for _, r := range report.GetResults() { + summary := benchkit.Summarize(r, trim) + entry.measurement.throughput += summary.Throughput + if entry.measurement.p50US == 0 { + entry.measurement.p50US = medianUS(summary.Dist()) + } + switch r.GetConfig().GetMeasurementMode() { + case benchkit.MeasurementMode_SERVER_MEASURED: + entry.server = true + case benchkit.MeasurementMode_CLIENT_MEASURED: + entry.client = true + } + } + list = append(list, entry) + } + + serverOnly := 0 + for _, n := range list { + if serverMeasuredOnly(n) { + serverOnly++ + } + } + // Prefer pure server-measured nodes for health when the run has both roles + // (primary client + backup execute thruput). + useServerOnly := serverOnly >= 2 + measurements := make(map[string]nodeMeasurement, len(list)) + for _, n := range list { + if useServerOnly && !serverMeasuredOnly(n) { + continue + } + measurements[n.host] = n.measurement + } + return measurements +} + +// findDegradedNodes returns the nodes whose measurement falls outside bounds, +// most extreme first. Each check needs at least two nodes carrying its metric +// and a positive run median: a run that produced no throughput at all is a +// failure, not a degradation, and one that recorded no latency has no latency +// median to judge against. A node is reported once, by the first bound it +// crosses in the order the bounds are declared. +func findDegradedNodes(nodes map[string]nodeMeasurement, bounds degradationBounds) []degradedNode { + if !bounds.enabled() || len(nodes) < 2 { + return nil + } + throughputs := make(map[string]float64, len(nodes)) + latencies := make(map[string]float64, len(nodes)) + for host, m := range nodes { + throughputs[host] = m.throughput + if m.p50US > 0 { + latencies[host] = m.p50US + } + } + tputMedian := runMedian(throughputs) + latencyMedian := runMedian(latencies) + + var flagged []degradedNode + for host, m := range nodes { + var reason string + relative := 0.0 + switch { + case tputMedian > 0 && bounds.below > 0 && m.throughput < bounds.below*tputMedian: + reason, relative = degradedSlow, m.throughput/tputMedian + case tputMedian > 0 && bounds.above > 0 && m.throughput > bounds.above*tputMedian: + reason, relative = degradedExcessThroughput, m.throughput/tputMedian + case latencyMedian > 0 && bounds.latencyBelow > 0 && m.p50US > 0 && m.p50US < bounds.latencyBelow*latencyMedian: + reason, relative = degradedFastLatency, m.p50US/latencyMedian + default: + continue + } + flagged = append(flagged, degradedNode{ + Host: host, + Reason: reason, + Throughput: m.throughput, + Relative: relative, + }) + } + // Most extreme first: the furthest from parity in either direction. + slices.SortFunc(flagged, func(a, b degradedNode) int { + return cmp.Or( + cmp.Compare(deviation(b.Relative), deviation(a.Relative)), + cmp.Compare(a.Host, b.Host), + ) + }) + return flagged +} + +// runMedian returns the median of the values, or 0 when fewer than two nodes +// carry the metric, which leaves no peer group to compare against. +func runMedian(values map[string]float64) float64 { + if len(values) < 2 { + return 0 + } + return stats.Median(slices.Sorted(maps.Values(values))) +} + +// deviation measures how far a median-relative ratio is from parity, so a node +// at 12x and one at 1/12 of the median rank equally extreme. +func deviation(relative float64) float64 { + if relative <= 0 { + return math.Inf(1) + } + return math.Abs(math.Log(relative)) +} diff --git a/benchkit/cmd/sweep/degraded_test.go b/benchkit/cmd/sweep/degraded_test.go new file mode 100644 index 00000000..6f2021c1 --- /dev/null +++ b/benchkit/cmd/sweep/degraded_test.go @@ -0,0 +1,251 @@ +package main + +import ( + "encoding/json" + "math" + "os" + "testing" + "time" + + "github.com/relab/gorums/benchkit" +) + +// TestFindDegradedNodes verifies the median-relative degradation checks: a node +// is flagged when its throughput falls below the given fraction of the run +// median, when it exceeds the given multiple of it (a symmetric benchmark cannot +// produce that, so the node recorded work it never did), or when its median +// latency is a fraction of its peers' (too fast for the round trip the benchmark +// measures). Healthy skew (±30% around the median is normal on real clusters) is +// never flagged at the default bounds, and a non-positive bound disables its +// check. +func TestFindDegradedNodes(t *testing.T) { + defaults := degradationBounds{below: 0.5, above: 2, latencyBelow: 0.2} + tputs := func(values map[string]float64) map[string]nodeMeasurement { + nodes := make(map[string]nodeMeasurement, len(values)) + for host, tput := range values { + nodes[host] = nodeMeasurement{throughput: tput} + } + return nodes + } + tests := []struct { + name string + nodes map[string]nodeMeasurement + bounds degradationBounds + want []string // flagged hosts, most extreme first + wantRel []float64 // relative_to_median per flagged host + wantReason []string // reason per flagged host + }{ + { + name: "healthy skew not flagged", + nodes: tputs(map[string]float64{ + "bb2:9000": 4014, "bb3:9000": 5020, "bb4:9000": 5968, + "bb5:9000": 6512, "bb6:9000": 7248, + }), + bounds: defaults, + }, + { + name: "pathological node flagged", + nodes: tputs(map[string]float64{ + "bb2:9000": 5000, "bb3:9000": 5500, "bb4:9000": 6000, + "bb5:9000": 5200, "bb16:9000": 233, + }), + bounds: defaults, + want: []string{"bb16:9000"}, + wantRel: []float64{233.0 / 5200.0}, + wantReason: []string{degradedSlow}, + }, + { + name: "two degraded nodes sorted most extreme first", + nodes: tputs(map[string]float64{ + "bb2:9000": 5000, "bb3:9000": 5000, "bb4:9000": 5000, + "bb16:9000": 250, "bb24:9000": 1000, + }), + bounds: defaults, + want: []string{"bb16:9000", "bb24:9000"}, + wantRel: []float64{0.05, 0.2}, + wantReason: []string{degradedSlow, degradedSlow}, + }, + { + // The run-over case: a node that kept recording operations after its + // peers finished reported 116x the run median. + name: "node far above the median flagged", + nodes: tputs(map[string]float64{ + "bb2:9000": 5000, "bb3:9000": 5500, "bb4:9000": 4500, + "bb9:9000": 517786, + }), + bounds: defaults, + want: []string{"bb9:9000"}, + wantRel: []float64{517786.0 / 5250.0}, // median of 4500, 5000, 5500, 517786 + wantReason: []string{degradedExcessThroughput}, + }, + { + name: "implausibly fast node flagged on latency alone", + nodes: map[string]nodeMeasurement{ + "bb2:9000": {throughput: 5000, p50US: 1400}, + "bb3:9000": {throughput: 5200, p50US: 1450}, + "bb4:9000": {throughput: 5100, p50US: 1400}, + "bb9:9000": {throughput: 7400, p50US: 7.4}, + }, + bounds: defaults, + want: []string{"bb9:9000"}, + wantRel: []float64{7.4 / 1400.0}, + wantReason: []string{degradedFastLatency}, + }, + { + name: "healthy latency skew not flagged", + nodes: map[string]nodeMeasurement{ + "bb2:9000": {throughput: 5000, p50US: 1400}, + "bb3:9000": {throughput: 5200, p50US: 900}, + "bb4:9000": {throughput: 5100, p50US: 1900}, + }, + bounds: defaults, + }, + { + name: "nodes without latency data judged on throughput only", + nodes: map[string]nodeMeasurement{ + "bb2:9000": {throughput: 5000}, + "bb3:9000": {throughput: 5200}, + "bb4:9000": {throughput: 5100}, + }, + bounds: defaults, + }, + { + name: "exactly at threshold not flagged", + nodes: tputs(map[string]float64{"bb2:9000": 1000, "bb3:9000": 1000, "bb4:9000": 500}), + bounds: defaults, + }, + { + name: "exactly at upper threshold not flagged", + nodes: tputs(map[string]float64{"bb2:9000": 1000, "bb3:9000": 1000, "bb4:9000": 2000}), + bounds: defaults, + }, + { + name: "disabled bounds", + nodes: tputs(map[string]float64{"bb2:9000": 5000, "bb16:9000": 1}), + bounds: degradationBounds{}, + }, + { + name: "upper bound alone", + nodes: tputs(map[string]float64{"bb2:9000": 5000, "bb3:9000": 5000, "bb16:9000": 1}), + bounds: degradationBounds{above: 2}, + }, + { + name: "single node never flagged", + nodes: tputs(map[string]float64{"bb2:9000": 5000}), + bounds: defaults, + }, + { + name: "zero median disables check", + nodes: tputs(map[string]float64{"bb2:9000": 0, "bb3:9000": 0, "bb4:9000": 0}), + bounds: defaults, + }, + { + name: "empty", + nodes: map[string]nodeMeasurement{}, + bounds: defaults, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := findDegradedNodes(tt.nodes, tt.bounds) + if len(got) != len(tt.want) { + t.Fatalf("flagged %d node(s) %v, want %d %v", len(got), got, len(tt.want), tt.want) + } + for i, d := range got { + if d.Host != tt.want[i] { + t.Errorf("flagged[%d].Host = %q, want %q", i, d.Host, tt.want[i]) + } + if math.Abs(d.Relative-tt.wantRel[i]) > 1e-9 { + t.Errorf("flagged[%d].Relative = %v, want %v", i, d.Relative, tt.wantRel[i]) + } + if d.Reason != tt.wantReason[i] { + t.Errorf("flagged[%d].Reason = %q, want %q", i, d.Reason, tt.wantReason[i]) + } + if want := tt.nodes[d.Host].throughput; d.Throughput != want { + t.Errorf("flagged[%d].Throughput = %v, want %v", i, d.Throughput, want) + } + } + }) + } +} + +// TestCollectNodeMeasurements verifies that each node's throughput and median +// latency are read from the collected result files keyed by host label, and that +// missing files are skipped (coverage is countResultFiles's job, not this one's). +func TestCollectNodeMeasurements(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N3_W1_P0" + nodes := []nodeAssignment{ + {host: "bb2", port: 9000}, + {host: "bb3", port: 9000}, + {host: "bb4", port: 9000}, // no result file written + } + writePlotReport(t, dir, base, nodes[0], "bb2:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 3, 1, 0, 0), + Throughput: 5000, + Latencies: []int64{1_000_000, 2_000_000, 3_000_000}, + }.Build()) + writePlotReport(t, dir, base, nodes[1], "bb3:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 3, 1, 0, 0), + Throughput: 233, + // No latency samples: p50 stays 0, which the latency bound skips. + }.Build()) + + got := collectNodeMeasurements(dir, base, nodes, 0) + want := map[string]nodeMeasurement{ + "bb2:9000": {throughput: 5000, p50US: 2000}, + "bb3:9000": {throughput: 233}, + } + if len(got) != len(want) { + t.Fatalf("measurements = %v, want %v", got, want) + } + for host, m := range want { + if got[host] != m { + t.Errorf("measurement[%q] = %+v, want %+v", host, got[host], m) + } + } +} + +// TestUpdateManifestOutcomeDegraded verifies that a degraded outcome records +// the degraded status and the flagged nodes with their relative throughput in +// the manifest. +func TestUpdateManifestOutcomeDegraded(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N5_W1_P0" + nodes := []nodeAssignment{{host: "bb2", port: 9000}, {host: "bb16", port: 9000}} + cfg := &config{sweepLabel: "e1", duration: time.Second} + writeManifest(dir, base, runSpec{ + Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 5, Workers: 1}, + Rep: 1, + }, nodes, cfg, "", "") + + deg := []degradedNode{{Host: "bb16:9000", Throughput: 233, Relative: 0.045}} + tcp := map[string]map[string]uint64{"bb16": {"TcpExt.TCPTimeouts": 4900}} + err := updateManifestOutcome(dir, base, runOutcome{ + status: runStatusDegraded, collectedFiles: 2, degraded: deg, tcpStats: tcp, + }) + if err != nil { + t.Fatalf("updateManifestOutcome: %v", err) + } + + data, err := os.ReadFile(manifestPath(dir, base)) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var m runManifest + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("parse manifest: %v", err) + } + if m.Status != runStatusDegraded { + t.Errorf("Status = %q, want %q", m.Status, runStatusDegraded) + } + if len(m.DegradedNodes) != 1 || m.DegradedNodes[0].Host != "bb16:9000" { + t.Fatalf("DegradedNodes = %+v, want bb16:9000", m.DegradedNodes) + } + if m.DegradedNodes[0].Relative != 0.045 { + t.Errorf("Relative = %v, want 0.045", m.DegradedNodes[0].Relative) + } + if m.TCPStats["bb16"]["TcpExt.TCPTimeouts"] != 4900 { + t.Errorf("TCPStats = %v, want bb16 TCPTimeouts=4900", m.TCPStats) + } +} diff --git a/benchkit/cmd/sweep/deploy.go b/benchkit/cmd/sweep/deploy.go new file mode 100644 index 00000000..4551b4db --- /dev/null +++ b/benchkit/cmd/sweep/deploy.go @@ -0,0 +1,809 @@ +package main + +import ( + "bufio" + "cmp" + "context" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/relab/iago" +) + +// defaultBinaryPath is the local binary built and deployed when -binary is unset. +const defaultBinaryPath = "./cmd/benchmark/benchmark" + +// remoteProgram is the program deployed in each host's per-user remote namespace. Every +// remote reference to the binary — its path, the pkill/pgrep pattern, and the +// cleanup command — derives from its basename, so deploying a differently named +// binary requires no other changes. +type remoteProgram struct { + name string // remote basename, e.g. "sweep-benchmark" +} + +// newRemoteProgram derives the remote program from the local binary path, +// prefixing the basename with "sweep-" so the pgrep/pkill patterns match only +// programs this tool deployed. An empty path falls back to the default binary. +func newRemoteProgram(binaryPath string) remoteProgram { + if binaryPath == "" { + binaryPath = defaultBinaryPath + } + return remoteProgram{name: "sweep-" + filepath.Base(binaryPath)} +} + +// path is the absolute path of the program on the remote hosts. +func (p remoteProgram) path(remoteDir string) string { return filepath.Join(remoteDir, p.name) } + +// pgrep returns the program name as a pgrep/pkill -f pattern with the first +// character bracketed so the matching command does not match itself. +func (p remoteProgram) pgrep() string { return "[" + p.name[:1] + "]" + p.name[1:] } + +// nodeAssignment describes one benchmark node in a distributed run. +type nodeAssignment struct { + host string // SSH alias; used for deployment, filenames, and logs + peerHost string // advertised benchmark address; empty means use host + port int +} + +// peerAddr returns the host:port address advertised to benchmark peers. +func (n nodeAssignment) peerAddr() string { + host := cmp.Or(n.peerHost, n.host) + return net.JoinHostPort(host, strconv.Itoa(n.port)) +} + +// hostAddr returns the SSH-alias host:port label used in local artifacts. +func (n nodeAssignment) hostAddr() string { + return net.JoinHostPort(n.host, strconv.Itoa(n.port)) +} + +// hostAssignment records how one SSH alias should be advertised to peers. +type hostAssignment struct { + alias string + peerHost string +} + +// buildNodeAssignments computes host/port assignments for n nodes. +// Nodes are distributed round-robin across the given hosts; nodes beyond +// len(hosts) get successive port offsets on the same hosts. +func buildNodeAssignments(hosts []hostAssignment, n, basePort int) []nodeAssignment { + numHosts := min(n, len(hosts)) + nodes := make([]nodeAssignment, n) + for i := range n { + host := hosts[i%numHosts] + nodes[i] = nodeAssignment{ + host: host.alias, + peerHost: host.peerHost, + port: basePort + (i / numHosts), + } + } + return nodes +} + +// resultExt is the on-disk extension for result files. Benchmark nodes always +// write the binary proto format (magic header + Report message). sweep also +// converts collected files to human-readable protojson ".json" siblings for +// manual inspection (see convertBinaryResults). +const resultExt = ".binpb" + +// resultFilename returns the result file basename for one node's run: +// "__". The remote path is this name under the host's +// configured per-user namespace. +func resultFilename(base string, node nodeAssignment, ext string) string { + return fmt.Sprintf("%s_%s_%d%s", base, node.host, node.port, ext) +} + +// buildPeerList returns a comma-separated "host:port" string for all nodes. +func buildPeerList(nodes []nodeAssignment) string { + peers := make([]string, len(nodes)) + for i, n := range nodes { + peers[i] = n.peerAddr() + } + return strings.Join(peers, ",") +} + +// resolvePeerHosts resolves one advertised benchmark address per SSH host. +// SSH aliases remain in use for deployment; the returned peerHost values are +// only passed to remote benchmark processes as -self/-remotes addresses. +func resolvePeerHosts(hosts []iago.Host, connectAddr func(string) string) ([]hostAssignment, error) { + assignments := make([]hostAssignment, len(hosts)) + for i, host := range hosts { + peerHost, err := resolvePeerHost(host.Name(), host.Address(), connectAddr(host.Name()), net.LookupIP) + if err != nil { + return nil, err + } + assignments[i] = hostAssignment{alias: host.Name(), peerHost: peerHost} + } + return assignments, nil +} + +type lookupIPFunc func(string) ([]net.IP, error) + +// resolvePeerHost chooses a stable, non-loopback IP address for one benchmark +// host. It prefers the already-connected SSH remote endpoint because SSH config +// HostName entries may be more specific than the alias. If that endpoint is not +// usable, it tries the SSH-config-expanded connect address, then the raw alias. +func resolvePeerHost(alias, sshAddr, sshConfigAddr string, lookup lookupIPFunc) (string, error) { + candidates := peerHostCandidates(alias, sshAddr, sshConfigAddr) + var errs []error + for _, candidate := range candidates { + if ip := net.ParseIP(candidate); ip != nil { + if usablePeerIP(ip) { + return ip.String(), nil + } + errs = append(errs, fmt.Errorf("%s: not a usable peer IP", candidate)) + continue + } + ips, err := lookup(candidate) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", candidate, err)) + continue + } + if ip := choosePeerIP(ips); ip != nil { + return ip.String(), nil + } + errs = append(errs, fmt.Errorf("%s: no usable non-loopback IP in %v", candidate, ips)) + } + return "", fmt.Errorf("resolve peer address for %s (ssh remote %q): %w", alias, sshAddr, errors.Join(errs...)) +} + +func peerHostCandidates(alias, sshAddr, sshConfigAddr string) []string { + var candidates []string + if sshAddr != "" { + candidates = append(candidates, hostFromAddr(sshAddr)) + } + if sshConfigAddr != "" { + host := hostFromAddr(sshConfigAddr) + if host != "" && !slices.Contains(candidates, host) { + candidates = append(candidates, host) + } + } + if alias != "" && !slices.Contains(candidates, alias) { + candidates = append(candidates, alias) + } + return candidates +} + +func hostFromAddr(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return strings.Trim(addr, "[]") + } + return host +} + +func choosePeerIP(ips []net.IP) net.IP { + for _, ip := range ips { + if ip4 := ip.To4(); usablePeerIP(ip4) { + return ip4 + } + } + for _, ip := range ips { + if usablePeerIP(ip) { + return ip + } + } + return nil +} + +func usablePeerIP(ip net.IP) bool { + return ip != nil && !ip.IsLoopback() && !ip.IsUnspecified() && !ip.IsMulticast() +} + +func peerHostSummary(hosts []hostAssignment) string { + parts := make([]string, len(hosts)) + for i, h := range hosts { + parts[i] = h.alias + "=" + h.peerHost + } + return strings.Join(parts, ", ") +} + +func resolveSSHConfigPath(configFile string) (string, error) { + if configFile != "" { + return configFile, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ssh", "config"), nil +} + +// withTimeout returns a copy of g with the given timeout. +func withTimeout(g iago.Group, d time.Duration) iago.Group { + g.Timeout = d + return g +} + +// run executes fn on all hosts in g concurrently, collecting all errors. +func run(g iago.Group, name string, fn func(context.Context, iago.Host) error) error { + var errs iago.Errors + g.ErrorHandler = errs.Handle + g.Run(name, fn) + return errs.Err() +} + +// buildBenchmark cross-compiles the benchmark binary for Linux/amd64 to +// outputPath. It must be run from the benchkit module root. +// +// When buildCmd is empty, it runs the built-in default (today's behavior): +// +// GOOS=linux GOARCH=amd64 go build -o ./cmd/benchmark/ +// +// Otherwise buildCmd is a user-supplied build command template (from the -build +// flag or the BENCHKIT_BUILD environment variable), letting a foreign protocol +// supply its own package path or toolchain. The chosen output path is +// substituted for the {{output}} token, or appended as "-o " if the +// token is absent. The command is run via "sh -c" from the benchkit module root with +// GOOS=linux GOARCH=amd64 appended to its environment (a build script may +// override these). +func buildBenchmark(outputPath, buildCmd string) error { + abs, err := filepath.Abs(outputPath) + if err != nil { + return err + } + var cmd *exec.Cmd + if buildCmd == "" { + log.Printf("building benchmark binary for linux/amd64 → %s", abs) + cmd = exec.Command("go", "build", "-o", abs, "./cmd/benchmark/") + } else { + script := expandBuildCmd(buildCmd, abs) + log.Printf("building benchmark binary for linux/amd64 via custom command → %s", abs) + log.Printf(" $ %s", script) + cmd = exec.Command("sh", "-c", script) + } + return runCrossBuild(cmd) +} + +// runCrossBuild sets the linux/amd64 cross-compile environment on cmd, routes +// its output to os.Stderr so a build never contaminates sweep's stdout, and +// runs it. It is the shared tail of the benchmark and sweep builders. +func runCrossBuild(cmd *exec.Cmd) error { + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64") + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// expandBuildCmd produces the shell script run for a custom build command. It +// substitutes the shell-quoted absolute output path for every {{output}} token; +// if the template has no such token, it appends "-o " so a bare +// "go build" still writes to the chosen path. The output path is single-quoted +// so paths containing spaces are passed as one argument. +func expandBuildCmd(tmpl, absOutput string) string { + out := iago.Quote(absOutput) + if strings.Contains(tmpl, "{{output}}") { + return strings.ReplaceAll(tmpl, "{{output}}", out) + } + return tmpl + " -o " + out +} + +// upload deploys the local binary to prog.path() on all hosts in g. +func upload(g iago.Group, localBinary string, cfg *config) error { + log.Printf("uploading binary to %d host(s)...", len(g.Hosts)) + return run(withTimeout(g, 5*time.Minute), "upload binary", func(ctx context.Context, host iago.Host) error { + if err := iago.UploadFile(ctx, host, localBinary, cfg.prog.path(cfg.remoteDirs[host.Name()]), iago.NewPerm(0o755)); err != nil { + return err + } + log.Printf(" ok uploaded to %s", host.Name()) + return nil + }) +} + +// killLingering terminates any running instances of prog on all hosts. +// It first sends SIGTERM, escalates to SIGKILL if instances survive the +// grace period, and polls to confirm they have exited. +func killLingering(g iago.Group, prog remoteProgram) error { + script := fmt.Sprintf(`pkill -f '%[1]s' || true +for i in $(seq 1 5); do + sleep 1 + pgrep -f '%[1]s' > /dev/null || exit 0 +done +pkill -KILL -f '%[1]s' || true +for i in $(seq 1 10); do + sleep 1 + pgrep -f '%[1]s' > /dev/null || exit 0 +done +echo "%[2]s still running after SIGKILL" >&2 +exit 1`, prog.pgrep(), prog.name) + return run(withTimeout(g, 30*time.Second), "kill lingering", func(ctx context.Context, host iago.Host) error { + err := iago.Shell{Command: script}.Apply(ctx, host) + if isSignalTerm(err) { + // session.Close() races with an already-exited shell and delivers SIGTERM; + // treat exit 143 as success since the kill script itself succeeded. + log.Printf(" ok cleared %s", host.Name()) + return nil + } + if err != nil { + return err + } + log.Printf(" ok cleared %s", host.Name()) + return nil + }) +} + +// portCheckScript returns a shell script that exits non-zero when any of the +// given ports has a listener, printing each busy port's ss line (including the +// owning process when visible) so the failure names the culprit. +func portCheckScript(ports []string) string { + return fmt.Sprintf(`busy= +for p in %s; do + if ss -ltnH 2>/dev/null | grep -qE ":$p([[:space:]]|$)"; then + busy="$busy $p" + ss -ltnpH 2>/dev/null | grep -E ":$p([[:space:]]|$)" || true + fi +done +[ -z "$busy" ]`, strings.Join(ports, " ")) +} + +// hostPortsByHost groups the nodes' listen ports (as strings) by host alias. +func hostPortsByHost(nodes []nodeAssignment) map[string][]string { + hostPorts := make(map[string][]string) + for _, n := range nodes { + hostPorts[n.host] = append(hostPorts[n.host], strconv.Itoa(n.port)) + } + return hostPorts +} + +// nodesByHost groups the node assignments by host alias. +func nodesByHost(nodes []nodeAssignment) map[string][]nodeAssignment { + hostNodes := make(map[string][]nodeAssignment) + for _, n := range nodes { + hostNodes[n.host] = append(hostNodes[n.host], n) + } + return hostNodes +} + +// checkPortsFree verifies that every node's listen port is free on its host. +// A node that cannot bind its port makes all peers wait out the full readiness +// deadline (2 minutes per run); this preflight turns that silent stall into an +// immediate error naming the busy port and, when visible, the process holding +// it. Run it after killLingering, which clears this sweep's own processes; a +// remaining listener belongs to someone else (e.g. a concurrent sweep). +func checkPortsFree(g iago.Group, nodes []nodeAssignment) error { + hostPorts := hostPortsByHost(nodes) + return run(withTimeout(g, 30*time.Second), "check ports", func(ctx context.Context, host iago.Host) error { + ports := hostPorts[host.Name()] + if len(ports) == 0 { + return nil + } + out, err := iago.Output(ctx, host, portCheckScript(ports)) + if err != nil { + // The script's own [ -z "$busy" ] exits 1 when it found busy + // ports (an ExitStatus error); anything else — a dial failure, a + // dropped session — never ran the check at all, so it must not + // be reported as "port(s) in use". + if _, ok := errors.AsType[iago.ExitStatus](err); ok { + return fmt.Errorf("%s: port(s) in use: %s", host.Name(), oneLine(out)) + } + return fmt.Errorf("%s: port check failed: %w", host.Name(), err) + } + return nil + }) +} + +// collectFailureDiag gathers a host snapshot from every host of a failed run +// and writes the per-host output to /logs/_snapshot.txt. The +// snapshot records load and fd limits (was the host overloaded?), benchmark +// processes still running (slow nodes lingering?), and the socket state on each +// node's port (listeners and TIME_WAIT connections to peers that already +// exited), which together explain post-mortem why a run failed. It is +// best-effort: any error is logged and suppressed so a diagnostic failure never +// masks the original run failure. It runs after the run fails, so most processes +// have exited, but TIME_WAIT sockets persist long enough to remain useful. +func collectFailureDiag(g iago.Group, nodes []nodeAssignment, prog remoteProgram, outdir, base string) { + hostPorts := hostPortsByHost(nodes) + + dir := filepath.Join(outdir, logSubdir) + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Printf(" warning: failure snapshot: %v", err) + return + } + f, err := os.Create(snapshotPath(outdir, base)) + if err != nil { + log.Printf(" warning: failure snapshot: %v", err) + return + } + defer f.Close() + + // Per-host blobs are written under a mutex so concurrent hosts do not + // interleave their output in the shared snapshot file. + var mu sync.Mutex + run(withTimeout(g, 30*time.Second), "failure snapshot", func(ctx context.Context, host iago.Host) error { + ports := hostPorts[host.Name()] + if len(ports) == 0 { + return nil + } + out, shErr := iago.Output(ctx, host, failureDiagCommand(ports, prog)) + mu.Lock() + defer mu.Unlock() + fmt.Fprintf(f, "===== %s =====\n", host.Name()) + if shErr != nil { + fmt.Fprintf(f, "(snapshot failed: %v)\n\n", shErr) + return nil + } + io.WriteString(f, out) + fmt.Fprintln(f) + return nil + }) +} + +// isSignalTerm reports whether err is an SSH exit caused by SIGTERM (status 143). +// This occurs when the SSH session is closed while the remote process has already +// exited, causing a spurious SIGTERM delivery. +func isSignalTerm(err error) bool { + exitErr, ok := errors.AsType[iago.ExitStatus](err) + return ok && exitErr.ExitStatus() == 143 +} + +// launchAndWait starts the benchmark on all assigned nodes and waits for all +// of them to finish. Both stdout and stderr from each remote process are streamed +// to the local log. Leaving either pipe unread exhausts the SSH channel window +// and blocks remote goroutines. +func launchAndWait(g iago.Group, nodes []nodeAssignment, peers string, spec runSpec, base string, cfg *config) error { + hostNodes := nodesByHost(nodes) + for _, h := range g.Hosts { + if _, ok := hostNodes[h.Name()]; !ok { + hostNodes[h.Name()] = nil + } + } + + // Node output streams to a per-run logger (console + /logs/.log) + // instead of sweep.log, so one experiment's output across all nodes can be + // read in isolation. Fall back to console-only if the log file cannot be + // created; node output stays visible and the run proceeds. + runLogger, closeLog, err := newRunLogger(cfg.outDir, base) + if err != nil { + log.Printf(" warning: per-run log: %v", err) + runLogger = log.New(os.Stderr, "", log.LstdFlags) + closeLog = func() error { return nil } + } + defer closeLog() + + launchTimeout := 5*time.Minute + cfg.duration + return run(withTimeout(g, launchTimeout), "run benchmark", func(ctx context.Context, host iago.Host) error { + myNodes := hostNodes[host.Name()] + log.Printf(" -> launching %d node(s) on %s...", len(myNodes), host.Name()) + + // Each node runs via RunContext, not Start+Wait, so the launchTimeout + // deadline is actually enforced: RunContext closes the SSH session when + // ctx fires, unblocking the wait even if the remote benchmark has hung + // (a bug in the benchmark, a wedged teardown, a lost peer). Start+Wait + // ignores ctx entirely — session.Wait() blocks until the remote process + // exits — so a single hung node would wedge the whole sweep indefinitely + // (the driver never advances and never writes exit.code). A node left + // running after the timeout is reaped by the next + // run's killLingering. Nodes run concurrently so one host's timeout does + // not serialize behind another's. + errs := make([]error, len(myNodes)) + var wg sync.WaitGroup + // Wait for every already-launched node on the way out, including a + // setup failure partway through the loop below: otherwise an earlier + // node's goroutines (and its two drain readers) outlive this + // function, writing to runLogger after closeLog has closed the file. + defer wg.Wait() + for i, node := range myNodes { + cmd, err := host.NewCommand() + if err != nil { + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + stdoutDone := make(chan struct{}) + stderrDone := make(chan struct{}) + go drain(stdout, node.host, node.port, runLogger, stdoutDone) + go drain(stderr, node.host, node.port, runLogger, stderrDone) + + wg.Go(func() { + // io.EOF is the benign session-close signal, not a run failure + // (see iago.Shell.Apply, which applies the same filter). + if err := cmd.RunContext(ctx, buildNodeCmd(node, peers, spec, base, cfg)); err != nil && err != io.EOF { + errs[i] = fmt.Errorf("node %s:%d: %w", node.host, node.port, err) + } + <-stdoutDone + <-stderrDone + }) + } + wg.Wait() + + joined := errors.Join(errs...) + if joined == nil { + log.Printf(" ok finished %d node(s) on %s", len(myNodes), host.Name()) + } + return joined + }) +} + +// collectResults downloads each node's per-run artifacts (one file per +// extension in exts; the result file, plus profiles when collected) from the +// remote host via SSH (cat over the existing connection) and writes them to +// outdir. Files keep their per-node names so the binary converter and the +// report generator can locate them. A file missing on the remote host is +// logged as a warning +// rather than aborting the collection, so a run with injected faults +// (-fault-kill-after) still yields the surviving nodes' results; the summary +// then reports what was collected. +func collectResults(g iago.Group, base string, nodes []nodeAssignment, cfg *config, exts []string) error { + hostNodes := nodesByHost(nodes) + + return run(withTimeout(g, 5*time.Minute), "collect results", func(ctx context.Context, host iago.Host) error { + for _, node := range hostNodes[host.Name()] { + for _, ext := range exts { + filename := resultFilename(base, node, ext) + remotePath := filepath.Join(node.remoteDir(cfg), filename) + log.Printf(" -> collecting %s from %s...", filename, host.Name()) + // Check first, distinguishing a missing file (salvage the + // rest of the run) from a transport failure (fail the run): + // a dropped connection during the cat below would otherwise + // look identical to a nonexistent file, and the run would be + // recorded succeeded with silently under-counted results. + exists, err := iago.FileExists(ctx, host, remotePath) + if err != nil { + return fmt.Errorf("checking %s on %s: %w", filename, host.Name(), err) + } + if !exists { + log.Printf(" warning: missing %s on %s", filename, host.Name()) + continue + } + localPath := filepath.Join(cfg.outDir, filename) + f, err := os.Create(localPath) + if err != nil { + return err + } + err = iago.Shell{ + Command: "cat " + iago.Quote(remotePath), + Stdout: f, + }.Apply(ctx, host) + err = errors.Join(err, f.Close()) + if err != nil { + // Drop the empty local file so downstream consumers see a + // missing node, not a corrupt result. + _ = os.Remove(localPath) + if _, ok := errors.AsType[iago.ExitStatus](err); ok { + // The file passed the existence check above but cat + // still failed (e.g. removed in between, or + // unreadable): salvage, matching the missing-file case. + log.Printf(" warning: %s on %s could not be read: %v", filename, host.Name(), err) + continue + } + return fmt.Errorf("collecting %s from %s: %w", filename, host.Name(), err) + } + log.Printf(" ok collected %s", filename) + } + } + return nil + }) +} + +// cleanup removes the deployed binary and only the result files this sweep +// created from all hosts. remoteFiles maps each host name to the absolute remote +// paths of the result files produced on that host; an unscoped remote glob is +// avoided so a concurrent sweep's (or another user's) output is never deleted. +// maxCleanupCmdBytes bounds each "rm -f" command's total length, with margin +// under Linux's 128 KiB MAX_ARG_STRLEN limit on a single argument to the +// remote shell (sshd execs "sh -c "). A long sweep with +// -collect-profiles can reach thousands of result paths per host; without +// chunking, one oversized command fails cleanup wholesale, leaving the +// binary and every result file behind. +const maxCleanupCmdBytes = 64 * 1024 + +func cleanup(g iago.Group, cfg *config, remoteFiles map[string][]string) error { + return run(withTimeout(g, time.Minute), "cleanup", func(ctx context.Context, host iago.Host) error { + paths := append([]string{cfg.prog.path(cfg.remoteDirs[host.Name()])}, remoteFiles[host.Name()]...) + for i := range paths { + paths[i] = iago.Quote(paths[i]) + } + for _, chunk := range chunkByLength(paths, maxCleanupCmdBytes) { + if err := (iago.Shell{Command: "rm -f " + strings.Join(chunk, " ")}).Apply(ctx, host); err != nil { + return err + } + } + return nil + }) +} + +// chunkByLength splits items into consecutive groups whose total joined +// length (one separating space between items) stays within maxBytes. Every +// group holds at least one item, even one that alone exceeds maxBytes, so no +// item is ever dropped. +func chunkByLength(items []string, maxBytes int) [][]string { + var chunks [][]string + var current []string + size := 0 + for _, item := range items { + if len(current) > 0 && size+1+len(item) > maxBytes { + chunks = append(chunks, current) + current = nil + size = 0 + } + if len(current) > 0 { + size++ // separating space + } + current = append(current, item) + size += len(item) + } + if len(current) > 0 { + chunks = append(chunks, current) + } + return chunks +} + +// buildNodeCmd constructs the remote command string for one benchmark node. +// The binary is expected at cfg.prog.path() on the remote host. +// +// Required flags that the benchmark binary must support: +// +// -self=host:port this node's listen address (triggers distributed mode) +// -remotes=addr,... comma-separated peer addresses +// -benchmarks=^name$ exact benchmark name (sweep anchors and QuoteMeta-escapes each entry) +// -workers=N concurrent goroutines +// -payload=N payload size in bytes +// -time=duration measurement duration +// -output=path output result file path +// -rate=N target sends/sec per node; 0 = unlimited (optional) +// -send-buffer=N per-node send queue capacity (optional) +// -recv-buffer=N server receive queue capacity (optional) +// -verbose verbose logging (optional) +// +// The pass-through flags (-interval, -stats-mode, -rate-step, +// -rate-step-max), -stream-mode (only for dedup), the per-node profile paths +// (-cpuprofile/-memprofile, when -collect-profiles is set), and -extra-args +// are appended only when set, so a binary without them keeps working as long +// as the sweep does not ask for them. +func buildNodeCmd(node nodeAssignment, peers string, spec runSpec, base string, cfg *config) string { + remoteDir := node.remoteDir(cfg) + output := filepath.Join(remoteDir, resultFilename(base, node, resultExt)) + parts := []string{ + iago.Quote(cfg.prog.path(remoteDir)), + fmt.Sprintf("-self=%s", node.peerAddr()), + fmt.Sprintf("-remotes=%s", peers), + fmt.Sprintf("-benchmarks=%s", iago.Quote("^"+regexp.QuoteMeta(spec.Benchmark)+"$")), + fmt.Sprintf("-workers=%d", spec.Workers), + fmt.Sprintf("-payload=%d", spec.Payload), + fmt.Sprintf("-time=%s", cfg.duration), + fmt.Sprintf("-output=%s", iago.Quote(output)), + } + if spec.Rate > 0 { + parts = append(parts, fmt.Sprintf("-rate=%d", spec.Rate)) + } + if spec.SendBuffer != 0 { + parts = append(parts, fmt.Sprintf("-send-buffer=%d", spec.SendBuffer)) + } + if spec.RecvBuffer != 0 { + parts = append(parts, fmt.Sprintf("-recv-buffer=%d", spec.RecvBuffer)) + } + if cfg.interval != "" { + parts = append(parts, fmt.Sprintf("-interval=%s", cfg.interval)) + } + if cfg.statsMode != "" { + parts = append(parts, fmt.Sprintf("-stats-mode=%s", cfg.statsMode)) + } + if cfg.rateStep > 0 { + parts = append(parts, fmt.Sprintf("-rate-step=%d", cfg.rateStep)) + } + if cfg.rateStepMax > 0 { + parts = append(parts, fmt.Sprintf("-rate-step-max=%d", cfg.rateStepMax)) + } + // Only dedup is passed through: dual is the binary default, and baseline + // runs a prebuilt binary from before the -stream-mode flag existed. + if spec.StreamMode == "dedup" { + parts = append(parts, fmt.Sprintf("-stream-mode=%s", spec.StreamMode)) + } + if cfg.collectProfiles { + parts = append(parts, + fmt.Sprintf("-cpuprofile=%s", iago.Quote(filepath.Join(remoteDir, resultFilename(base, node, cpuProfExt)))), + fmt.Sprintf("-memprofile=%s", iago.Quote(filepath.Join(remoteDir, resultFilename(base, node, memProfExt))))) + } + if cfg.verbose { + parts = append(parts, "-verbose") + } + if cfg.extraArgs != "" { + parts = append(parts, cfg.extraArgs) + } + cmd := strings.Join(parts, " ") + if stmt := fdLimitStmt(cfg.fdLimit); stmt != "" { + // The node runs through the remote login shell, so a ulimit builtin ahead + // of it raises the soft open-file limit for the benchmark process. The + // exit status of the "stmt; cmd" sequence is the benchmark's, so cmd.Wait + // still observes the node's real result. + cmd = stmt + "; " + cmd + } + return cmd +} + +func (n nodeAssignment) remoteDir(cfg *config) string { + if dir := cfg.remoteDirs[n.host]; dir != "" { + return dir + } + // Tests and callers constructing configs directly retain the historical + // location unless they opt into a configured namespace. + return "/tmp" +} + +// fdLimitStmt returns a shell statement that raises the soft open-file limit to +// n descriptors, or "" when n <= 0. The 2>/dev/null suppresses the shell's error +// if n exceeds the hard limit; in that case the limit is left unchanged and the +// command that follows still runs. A large benchmark mesh opens many concurrent +// connections per node, so the common 1024 soft default is easily exhausted. +func fdLimitStmt(n int) string { + if n <= 0 { + return "" + } + return fmt.Sprintf("ulimit -Sn %d 2>/dev/null", n) +} + +// drain reads rc line-by-line and logs each line prefixed with host:port using +// the given per-run logger, which writes to the console and the run's log file +// rather than to sweep.log. Closes done when the reader is exhausted. +// +// The scanner's buffer is grown well past bufio.Scanner's 64 KiB default (to +// match offsets.go's own scan of this same log content): a line past the +// default cap stops the scan with bufio.ErrTooLong, and launchAndWait's own +// doc explains why that must not leave rc unread — the SSH channel window +// fills and the remote process blocks on its next write, wedging the run +// until the launch timeout. On any scan error, keep draining rc to +// io.Discard so the channel stays readable even though lines can no longer +// be logged. +func drain(rc io.ReadCloser, host string, port int, logger *log.Logger, done chan struct{}) { + defer close(done) + scanner := bufio.NewScanner(rc) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + logger.Printf("[%s:%d] %s", host, port, scanner.Text()) + } + if err := scanner.Err(); err != nil { + logger.Printf("[%s:%d] pipe read error: %v", host, port, err) + _, _ = io.Copy(io.Discard, rc) + } +} + +// logSubdir is the subdirectory under the output directory that holds per-run +// node logs, one .log per run. Keeping them out of the top level leaves +// the output directory dominated by result files and manifests. +const logSubdir = "logs" + +// runLogPath returns the per-run node log path for base under outdir. +func runLogPath(outdir, base string) string { + return filepath.Join(outdir, logSubdir, base+".log") +} + +// snapshotPath returns the failure-diagnostic snapshot path for base under +// outdir; see collectFailureDiag. +func snapshotPath(outdir, base string) string { + return filepath.Join(outdir, logSubdir, base+"_snapshot.txt") +} + +// newRunLogger creates the per-run node log /logs/.log and returns +// a logger writing to both that file and the console, plus a close function. +// Each run's node output (all nodes interleaved chronologically) is isolated in +// its own file so one experiment can be inspected across all nodes without +// wading through every other run's output; sweep.log keeps only orchestration. +func newRunLogger(outdir, base string) (*log.Logger, func() error, error) { + dir := filepath.Join(outdir, logSubdir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, nil, err + } + f, err := os.Create(runLogPath(outdir, base)) + if err != nil { + return nil, nil, err + } + logger := log.New(io.MultiWriter(os.Stderr, f), "", log.LstdFlags) + return logger, f.Close, nil +} diff --git a/benchkit/cmd/sweep/deploy_test.go b/benchkit/cmd/sweep/deploy_test.go new file mode 100644 index 00000000..b9cb456e --- /dev/null +++ b/benchkit/cmd/sweep/deploy_test.go @@ -0,0 +1,656 @@ +package main + +import ( + "bytes" + "io" + "log" + "net" + "slices" + "strings" + "testing" + "time" + + "github.com/relab/gorums/benchkit" +) + +// TestBuildNodeCmd verifies the remote command line for one node: the required +// contract flags are always present, while rate, the pass-through flags, and +// -extra-args are appended only when set. +func TestBuildNodeCmd(t *testing.T) { + node := nodeAssignment{host: "bb1", port: 9000} + const peers = "bb1:9000,bb2:9000" + const base = "run_Symmetric_N2_W1_P0" + params := runSpec{Dimensions: benchkit.Dimensions{ + Nodes: 2, Workers: 1, Payload: 0, Benchmark: "Symmetric", + }} + const required = "'/tmp/sweep-benchmark' -self=bb1:9000 -remotes=bb1:9000,bb2:9000" + + " -benchmarks='^Symmetric$' -workers=1 -payload=0 -time=10s" + + " -output='/tmp/run_Symmetric_N2_W1_P0_bb1_9000.binpb'" + + tests := []struct { + name string + cfg config + rate int + streamMode string + want string + }{ + { + name: "DefaultsOmitOptionalFlags", + want: required, + }, + { + name: "ExplicitDualOmitted", + streamMode: "dual", + want: required, + }, + { + name: "RateAppendedWhenSet", + rate: 5000, + want: required + " -rate=5000", + }, + { + name: "PassThroughFlagsAppendedWhenSet", + cfg: config{ + interval: "250ms", + statsMode: "hdr", + rateStep: 1000, + rateStepMax: 8000, + }, + rate: 1000, + want: required + " -rate=1000 -interval=250ms -stats-mode=hdr" + + " -rate-step=1000 -rate-step-max=8000", + }, + { + name: "StreamDedupAppendedWhenSet", + streamMode: "dedup", + want: required + " -stream-mode=dedup", + }, + { + name: "BaselineOmitted", + streamMode: "baseline", + want: required, + }, + { + name: "ExtraArgsAppendedVerbatim", + cfg: config{extraArgs: "-quorum-size=3 -send-buffer=64"}, + want: required + " -quorum-size=3 -send-buffer=64", + }, + { + name: "VerboseBeforeExtraArgs", + cfg: config{verbose: true, extraArgs: "-quorum-size=3"}, + want: required + " -verbose -quorum-size=3", + }, + { + name: "CollectProfilesAppendsProfileFlags", + cfg: config{collectProfiles: true}, + want: required + " -cpuprofile='/tmp/run_Symmetric_N2_W1_P0_bb1_9000.cpu.prof'" + + " -memprofile='/tmp/run_Symmetric_N2_W1_P0_bb1_9000.mem.prof'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.cfg + cfg.prog = newRemoteProgram("") + cfg.duration = 10 * time.Second + p := params + p.Rate = tt.rate + p.StreamMode = tt.streamMode + if got := buildNodeCmd(node, peers, p, base, &cfg); got != tt.want { + t.Errorf("buildNodeCmd =\n %q\nwant\n %q", got, tt.want) + } + }) + } +} + +func TestBuildNodeCmdUsesConfiguredRemoteNamespace(t *testing.T) { + node := nodeAssignment{host: "bb1", port: 9000} + cfg := &config{ + duration: time.Second, + prog: newRemoteProgram("benchmark"), + remoteDirs: map[string]string{"bb1": "/local/sweep meling"}, + } + got := buildNodeCmd(node, "bb1:9000", runSpec{ + Dimensions: benchkit.Dimensions{Benchmark: "Symmetric"}, + }, "run", cfg) + for _, want := range []string{ + "'/local/sweep meling/sweep-benchmark'", + "-output='/local/sweep meling/run_bb1_9000.binpb'", + } { + if !strings.Contains(got, want) { + t.Fatalf("buildNodeCmd() missing %q:\n%s", want, got) + } + } +} + +// TestBuildNodeCmdQuotesResultPaths verifies that -output, -cpuprofile, and +// -memprofile are each shell-quoted as a single argument. base embeds the +// user-chosen -sweep label verbatim (see runBase), so a label containing a +// space or shell metacharacter previously broke every remote node launch, +// since -benchmarks was quoted but these path flags were not. +func TestBuildNodeCmdQuotesResultPaths(t *testing.T) { + node := nodeAssignment{host: "bb1", port: 9000} + cfg := &config{duration: time.Second, prog: newRemoteProgram(""), collectProfiles: true} + const base = "exp 1" // a label with a space, as runBase would produce + + got := buildNodeCmd(node, "bb1:9000", runSpec{Dimensions: benchkit.Dimensions{Benchmark: "Q"}}, base, cfg) + for _, want := range []string{ + "-output='/tmp/exp 1_bb1_9000.binpb'", + "-cpuprofile='/tmp/exp 1_bb1_9000.cpu.prof'", + "-memprofile='/tmp/exp 1_bb1_9000.mem.prof'", + } { + if !strings.Contains(got, want) { + t.Errorf("buildNodeCmd missing %q:\n%s", want, got) + } + } +} + +func TestBuildNodeCmdBufferFlags(t *testing.T) { + node := nodeAssignment{host: "bb1", port: 9000} + cfg := &config{duration: time.Second, prog: newRemoteProgram("")} + base := runSpec{Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 1, Workers: 1, + }} + + if got := buildNodeCmd(node, "bb1:9000", base, "run", cfg); strings.Contains(got, "-send-buffer") || strings.Contains(got, "-recv-buffer") { + t.Fatalf("zero buffers emitted flags: %s", got) + } + base.SendBuffer, base.RecvBuffer = 256, 16 + got := buildNodeCmd(node, "bb1:9000", base, "run", cfg) + for _, want := range []string{"-send-buffer=256", "-recv-buffer=16"} { + if !strings.Contains(got, want) { + t.Errorf("buildNodeCmd missing %q: %s", want, got) + } + } +} + +func TestFdLimitStmt(t *testing.T) { + if got := fdLimitStmt(0); got != "" { + t.Errorf("fdLimitStmt(0) = %q, want empty", got) + } + if got := fdLimitStmt(-1); got != "" { + t.Errorf("fdLimitStmt(-1) = %q, want empty", got) + } + if got, want := fdLimitStmt(65536), "ulimit -Sn 65536 2>/dev/null"; got != want { + t.Errorf("fdLimitStmt(65536) = %q, want %q", got, want) + } +} + +func TestBuildNodeCmdFdLimit(t *testing.T) { + node := nodeAssignment{host: "bb1", port: 9000} + const peers = "bb1:9000,bb2:9000" + const base = "run_Symmetric_N2_W1_P0" + p := runSpec{Dimensions: benchkit.Dimensions{ + Nodes: 2, Workers: 1, Payload: 0, Benchmark: "Symmetric", + }} + + // With a limit set, the ulimit statement prefixes the node command so it runs + // under the raised soft limit; the benchmark's exit status still propagates. + cfg := config{prog: newRemoteProgram(""), duration: 10 * time.Second, fdLimit: 65536} + got := buildNodeCmd(node, peers, p, base, &cfg) + if !strings.HasPrefix(got, "ulimit -Sn 65536 2>/dev/null; '/tmp/sweep-benchmark' ") { + t.Errorf("buildNodeCmd missing fd-limit prefix\ngot: %s", got) + } + + // With the limit disabled, the command is unchanged (no shell prefix). + cfg.fdLimit = 0 + if got := buildNodeCmd(node, peers, p, base, &cfg); strings.Contains(got, "ulimit") { + t.Errorf("buildNodeCmd added ulimit with fdLimit=0\ngot: %s", got) + } +} + +func TestBuildNodeCmdUsesPeerAddressForGorumsAndAliasForArtifacts(t *testing.T) { + node := nodeAssignment{host: "bb25", peerHost: "152.94.162.19", port: 9000} + peers := buildPeerList([]nodeAssignment{ + {host: "bb16", peerHost: "152.94.162.26", port: 9000}, + node, + }) + const base = "run_Symmetric_N2_W1_P0" + params := runSpec{Dimensions: benchkit.Dimensions{ + Nodes: 2, Workers: 1, Payload: 0, Benchmark: "Symmetric", + }} + cfg := &config{ + prog: newRemoteProgram(""), + duration: 10 * time.Second, + } + want := "'/tmp/sweep-benchmark' -self=152.94.162.19:9000" + + " -remotes=152.94.162.26:9000,152.94.162.19:9000" + + " -benchmarks='^Symmetric$' -workers=1 -payload=0 -time=10s" + + " -output='/tmp/run_Symmetric_N2_W1_P0_bb25_9000.binpb'" + if got := buildNodeCmd(node, peers, params, base, cfg); got != want { + t.Errorf("buildNodeCmd =\n %q\nwant\n %q", got, want) + } +} + +func TestResolvePeerHost(t *testing.T) { + dnsErr := func(name string) error { + return &net.DNSError{Err: "no such host", Name: name} + } + lookupFrom := func(records map[string][]net.IP) lookupIPFunc { + return func(name string) ([]net.IP, error) { + ips, ok := records[name] + if !ok { + return nil, dnsErr(name) + } + return ips, nil + } + } + + tests := []struct { + name string + alias string + sshAddr string + cfgAddr string + records map[string][]net.IP + want string + wantErr string + }{ + { + name: "NumericSSHAddress", + alias: "bb1", + sshAddr: "152.94.162.11:22", + want: "152.94.162.11", + }, + { + name: "ResolveSSHHostPreferIPv4", + alias: "bb1", + sshAddr: "bb1.example.test:22", + records: map[string][]net.IP{ + "bb1.example.test": { + net.ParseIP("2001:db8::1"), + net.ParseIP("152.94.162.11"), + }, + }, + want: "152.94.162.11", + }, + { + name: "FallbackToAlias", + alias: "bb1", + sshAddr: "proxy-name:22", + records: map[string][]net.IP{ + "bb1": {net.ParseIP("152.94.162.11")}, + }, + want: "152.94.162.11", + }, + { + name: "UseSSHConfigHostnameAfterProxyJumpRemoteAddr", + alias: "bb1", + sshAddr: "0.0.0.0:0", + cfgAddr: "bb1.ux.uis.no:22", + records: map[string][]net.IP{ + "bb1.ux.uis.no": {net.ParseIP("152.94.162.11")}, + }, + want: "152.94.162.11", + }, + { + name: "RejectLoopback", + alias: "bb1", + sshAddr: "127.0.0.1:22", + records: map[string][]net.IP{ + "bb1": {net.ParseIP("152.94.162.11")}, + }, + want: "152.94.162.11", + }, + { + name: "NoUsableAddress", + alias: "bb1", + sshAddr: "localhost:22", + records: map[string][]net.IP{ + "localhost": {net.ParseIP("127.0.0.1")}, + "bb1": {net.ParseIP("127.0.1.1")}, + }, + wantErr: "no usable", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolvePeerHost(tt.alias, tt.sshAddr, tt.cfgAddr, lookupFrom(tt.records)) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("resolvePeerHost error = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolvePeerHost: %v", err) + } + if got != tt.want { + t.Errorf("resolvePeerHost = %q, want %q", got, tt.want) + } + }) + } +} + +func TestPeerHostSummary(t *testing.T) { + hosts := []hostAssignment{ + {alias: "bb1", peerHost: "152.94.162.11"}, + {alias: "bb2", peerHost: "152.94.162.12"}, + } + if got, want := peerHostSummary(hosts), "bb1=152.94.162.11, bb2=152.94.162.12"; got != want { + t.Errorf("peerHostSummary = %q, want %q", got, want) + } +} + +// TestPortCheckScript verifies the preflight script probes exactly the given +// ports and fails (non-empty $busy) when any of them has a listener. +func TestPortCheckScript(t *testing.T) { + const want = `busy= +for p in 9000 9001; do + if ss -ltnH 2>/dev/null | grep -qE ":$p([[:space:]]|$)"; then + busy="$busy $p" + ss -ltnpH 2>/dev/null | grep -E ":$p([[:space:]]|$)" || true + fi +done +[ -z "$busy" ]` + if got := portCheckScript([]string{"9000", "9001"}); got != want { + t.Errorf("portCheckScript =\n%s\nwant\n%s", got, want) + } +} + +// TestBuildNodeAssignments verifies the round-robin host placement and the +// basePort + i/numHosts port-offset arithmetic that every result filename, +// port check, and manifest entry is built from. +func TestBuildNodeAssignments(t *testing.T) { + h := func(alias string) hostAssignment { return hostAssignment{alias: alias} } + + tests := []struct { + name string + hosts []hostAssignment + n int + basePort int + want []nodeAssignment + }{ + { + name: "OneNodePerHost", + hosts: []hostAssignment{h("bb1"), h("bb2")}, + n: 2, + basePort: 9000, + want: []nodeAssignment{ + {host: "bb1", port: 9000}, + {host: "bb2", port: 9000}, + }, + }, + { + name: "UnevenRatioAcrossTwoHosts", + hosts: []hostAssignment{h("bb1"), h("bb2")}, + n: 5, + basePort: 9000, + want: []nodeAssignment{ + {host: "bb1", port: 9000}, + {host: "bb2", port: 9000}, + {host: "bb1", port: 9001}, + {host: "bb2", port: 9001}, + {host: "bb1", port: 9002}, + }, + }, + { + name: "FewerNodesThanHostsUsesOnlyFirstN", + hosts: []hostAssignment{h("bb1"), h("bb2"), h("bb3")}, + n: 1, + basePort: 9000, + want: []nodeAssignment{ + {host: "bb1", port: 9000}, + }, + }, + { + name: "SingleHostStacksPorts", + hosts: []hostAssignment{h("bb1")}, + n: 3, + basePort: 9000, + want: []nodeAssignment{ + {host: "bb1", port: 9000}, + {host: "bb1", port: 9001}, + {host: "bb1", port: 9002}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildNodeAssignments(tt.hosts, tt.n, tt.basePort) + if !slices.Equal(got, tt.want) { + t.Errorf("buildNodeAssignments(%v, %d, %d) =\n %v\nwant\n %v", + tt.hosts, tt.n, tt.basePort, got, tt.want) + } + }) + } +} + +func TestResultFilename(t *testing.T) { + tests := []struct { + name string + base string + node nodeAssignment + ext string + want string + }{ + { + name: "json extension", + base: "nscale_SymmetricQuorumCall_N9_C1_P0", + node: nodeAssignment{host: "bb1", port: 9000}, + ext: ".json", + want: "nscale_SymmetricQuorumCall_N9_C1_P0_bb1_9000.json", + }, + { + name: "binary extension with port offset on shared host", + base: "test_Symmetric_N60_C1_P0", + node: nodeAssignment{host: "bb30", port: 9001}, + ext: ".binpb", + want: "test_Symmetric_N60_C1_P0_bb30_9001.binpb", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resultFilename(tt.base, tt.node, tt.ext); got != tt.want { + t.Errorf("resultFilename(%q, %+v, %q) = %q, want %q", tt.base, tt.node, tt.ext, got, tt.want) + } + }) + } +} + +func TestExpandBuildCmd(t *testing.T) { + const out = "/work/gorums/cmd/benchmark/benchmark" + const quoted = "'/work/gorums/cmd/benchmark/benchmark'" + tests := []struct { + name string + tmpl string + abs string + want string + }{ + { + name: "token substituted", + tmpl: "go build -o {{output}} ./cmd/pbft-bench", + abs: out, + want: "go build -o " + quoted + " ./cmd/pbft-bench", + }, + { + name: "token in make variable", + tmpl: "make pbft-bench OUT={{output}}", + abs: out, + want: "make pbft-bench OUT=" + quoted, + }, + { + name: "no token appends -o", + tmpl: "go build ./cmd/pbft-bench", + abs: out, + want: "go build ./cmd/pbft-bench -o " + quoted, + }, + { + name: "path with spaces stays one argument", + tmpl: "go build ./cmd/benchmark", + abs: "/home/Team UIS/gorums/bench", + want: "go build ./cmd/benchmark -o '/home/Team UIS/gorums/bench'", + }, + { + name: "single quote in path is escaped", + tmpl: "go build {{output}}", + abs: "/tmp/a'b/bench", + want: `go build '/tmp/a'\''b/bench'`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := expandBuildCmd(tt.tmpl, tt.abs); got != tt.want { + t.Errorf("expandBuildCmd(%q, %q) = %q, want %q", tt.tmpl, tt.abs, got, tt.want) + } + }) + } +} + +func TestNewRemoteProgram(t *testing.T) { + tests := []struct { + name string + binaryPath string + wantName string + wantPath string + wantPgrep string + }{ + { + name: "empty path uses default binary", + binaryPath: "", + wantName: "sweep-benchmark", + wantPath: "/tmp/sweep-benchmark", + wantPgrep: "[s]weep-benchmark", + }, + { + name: "default binary path", + binaryPath: defaultBinaryPath, + wantName: "sweep-benchmark", + wantPath: "/tmp/sweep-benchmark", + wantPgrep: "[s]weep-benchmark", + }, + { + name: "custom binary keeps its basename", + binaryPath: "/some/dir/myprog", + wantName: "sweep-myprog", + wantPath: "/tmp/sweep-myprog", + wantPgrep: "[s]weep-myprog", + }, + { + name: "bare basename", + binaryPath: "raft-bench", + wantName: "sweep-raft-bench", + wantPath: "/tmp/sweep-raft-bench", + wantPgrep: "[s]weep-raft-bench", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prog := newRemoteProgram(tt.binaryPath) + if prog.name != tt.wantName { + t.Errorf("name = %q, want %q", prog.name, tt.wantName) + } + if got := prog.path("/tmp"); got != tt.wantPath { + t.Errorf("path() = %q, want %q", got, tt.wantPath) + } + if got := prog.pgrep(); got != tt.wantPgrep { + t.Errorf("pgrep() = %q, want %q", got, tt.wantPgrep) + } + }) + } +} + +// TestDrainHandlesLineOverDefaultScannerLimit verifies that drain logs a line +// well past bufio.Scanner's 64 KiB default token size (a large diagnostic +// dump or panic trace is a realistic case), instead of stopping the scan with +// bufio.ErrTooLong the way the un-grown default buffer would. +func TestDrainHandlesLineOverDefaultScannerLimit(t *testing.T) { + longLine := strings.Repeat("x", 100*1024) // over the 64 KiB default, under the 1 MiB cap + rc := io.NopCloser(strings.NewReader(longLine + "\nshort\n")) + var buf bytes.Buffer + logger := log.New(&buf, "", 0) + done := make(chan struct{}) + + drain(rc, "host1", 9000, logger, done) + <-done + + got := buf.String() + if !strings.Contains(got, longLine) { + t.Error("drain did not log the long line; the scanner buffer was not grown") + } + if !strings.Contains(got, "short") { + t.Error("drain did not log the line following the long one") + } +} + +// drainCountingReader wraps a Reader and counts the bytes actually read from +// it, so a test can confirm a reader was fully drained rather than abandoned +// partway through. +type drainCountingReader struct { + r io.Reader + total int +} + +func (d *drainCountingReader) Read(p []byte) (int, error) { + n, err := d.r.Read(p) + d.total += n + return n, err +} + +// TestDrainKeepsReadingAfterScanTooLong verifies that drain does not abandon +// rc after a scan error (a line exceeding even the grown 1 MiB buffer): it +// must keep consuming rc to EOF so the underlying pipe (an SSH channel, in +// production) does not fill and wedge the remote process's next write, an +// invariant launchAndWait's own doc warns about. +func TestDrainKeepsReadingAfterScanTooLong(t *testing.T) { + tooLong := strings.Repeat("y", 2*1024*1024) // over the 1 MiB cap + trailing := "\nmore data after the oversized line\n" + src := &drainCountingReader{r: strings.NewReader(tooLong + trailing)} + rc := io.NopCloser(src) + var buf bytes.Buffer + logger := log.New(&buf, "", 0) + done := make(chan struct{}) + + drain(rc, "host1", 9000, logger, done) + <-done + + if !strings.Contains(buf.String(), "pipe read error") { + t.Error("drain did not log the scan error") + } + wantTotal := len(tooLong) + len(trailing) + if src.total < wantTotal { + t.Errorf("bytes read from rc = %d, want at least %d (drain must keep draining after a scan error)", src.total, wantTotal) + } +} + +// TestChunkByLength verifies the grouping cleanup relies on to keep each "rm +// -f" command under Linux's MAX_ARG_STRLEN: consecutive items are packed into +// a group up to maxBytes, a new group starts before exceeding it, and no +// item is ever dropped, even a single oversized one. +func TestChunkByLength(t *testing.T) { + tests := []struct { + name string + items []string + maxBytes int + want [][]string + }{ + {"Empty", nil, 10, nil}, + {"AllFitInOneChunk", []string{"a", "b", "c"}, 10, [][]string{{"a", "b", "c"}}}, + { + name: "SplitsWhenExceedingLimit", + items: []string{"aaa", "bbb", "ccc", "ddd"}, + maxBytes: 7, // "aaa bbb" = 7 fits; adding " ccc" would exceed it + want: [][]string{{"aaa", "bbb"}, {"ccc", "ddd"}}, + }, + { + name: "SingleItemExceedingLimitKeptAlone", + items: []string{"short", "way-too-long-item", "short2"}, + maxBytes: 5, + want: [][]string{{"short"}, {"way-too-long-item"}, {"short2"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := chunkByLength(tt.items, tt.maxBytes) + if !slices.EqualFunc(got, tt.want, slices.Equal) { + t.Errorf("chunkByLength(%v, %d) = %v, want %v", tt.items, tt.maxBytes, got, tt.want) + } + var gotItems []string + for _, chunk := range got { + gotItems = append(gotItems, chunk...) + } + if !slices.Equal(gotItems, tt.items) { + t.Errorf("chunkByLength dropped or reordered items: got %v, want %v", gotItems, tt.items) + } + }) + } +} diff --git a/benchkit/cmd/sweep/diag.go b/benchkit/cmd/sweep/diag.go new file mode 100644 index 00000000..96154fe0 --- /dev/null +++ b/benchkit/cmd/sweep/diag.go @@ -0,0 +1,421 @@ +package main + +import ( + "context" + "fmt" + "log" + "maps" + "os/exec" + "slices" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/relab/iago" +) + +// numProbePorts is the number of consecutive ports checked starting at the base +// port, covering up to four benchmark servers per host (N=120 across 30 hosts). +const numProbePorts = 4 + +// hostDiag holds the diagnostics gathered from a single host. +type hostDiag struct { + alias string + reachable bool + errMsg string + + diagFields + + skew time.Duration // remote clock minus local clock (approximate) + rtt time.Duration // SSH round-trip for the probe; bounds skew uncertainty + + // Since-boot TCP health counters (see checkTCPAllowlist). retransSegs over + // outSegs is the retransmit ratio; synRetrans counts loss during connection + // setup. outSegs == 0 means the host reported no TCP counters. + retransSegs uint64 + outSegs uint64 + synRetrans uint64 +} + +// checkTCPAllowlist names the /proc/net counters read for the -check table. +// It is separate from the manifest's tcpCounterAllowlist (see tcpstats.go): the +// check reports a since-boot retransmit ratio (RetransSegs/OutSegs) and the +// setup-loss counter (TCPSynRetrans), so it needs OutSegs as the denominator, +// which the per-run manifest deltas do not record. +var checkTCPAllowlist = map[string]bool{ + "Tcp.RetransSegs": true, + "Tcp.OutSegs": true, + "TcpExt.TCPSynRetrans": true, +} + +// checkConcurrency bounds how many hosts are dialed concurrently; the shared +// jump connection means this no longer caps TCP connections to the jump host. +const checkConcurrency = 30 + +// checkLocalTimeout bounds the driver's local self-probe so a wedged diagnostic +// command cannot hang the whole check. +const checkLocalTimeout = 15 * time.Second + +// checkHosts runs connectivity and host diagnostics on each alias using the same +// iago SSH path the sweep uses, so a clean result means the sweep will connect. +// For each host it reports reachability, host info (hostname, kernel, CPUs, +// load), whether any benchmark port in [basePort, basePort+numProbePorts) is in +// use, the count of lingering processes from a prior sweep, and the host's +// clock skew relative to the local machine. +// +// All aliases are dialed through a single shared jump connection (one TCP +// connection to the jump host regardless of the number of targets) using +// iago.DialConcurrency to dial target hosts concurrently. An unreachable or +// slow host is reported as UNREACHABLE rather than aborting the whole check. +// +// selfAlias, when non-empty and present in aliases, is the host this check is +// running on (the driver of a driver-routed check). It is probed locally rather +// than dialed: a host cannot SSH to itself through the generated config — the +// loopback self-connection fails the SSH handshake with EOF — so dialing it +// would always report the driver UNREACHABLE even though it is right here. +// Results are printed as an aligned table sorted by alias. +func checkHosts(aliases []string, sshConfig string, basePort int, prog remoteProgram, selfAlias, remoteRoot string) error { + command := diagCommand(basePort, prog, remoteRoot) + results := make(map[string]hostDiag, len(aliases)) + + // Probe the driver host directly and drop it from the set dialed over SSH. + remote := aliases + if selfAlias != "" && slices.Contains(aliases, selfAlias) { + results[selfAlias] = diagLocal(selfAlias, command) + remote = slices.DeleteFunc(slices.Clone(aliases), func(a string) bool { return a == selfAlias }) + } + + if len(remote) > 0 { + // Dial the remaining aliases concurrently through a single shared jump + // connection. Dial failures are collected in group.DialErrors rather + // than aborting. + group, err := iago.NewSSHGroup(remote, sshConfig, iago.DialConcurrency(checkConcurrency)) + if err != nil { + for _, alias := range remote { + results[alias] = hostDiag{alias: alias, errMsg: oneLine(err.Error())} + } + } else { + defer group.Close() + for alias, dialErr := range group.DialErrors { + results[alias] = hostDiag{alias: alias, errMsg: oneLine(dialErr.Error())} + } + collected, _ := iago.Collect(group, "diag", func(ctx context.Context, host iago.Host) (hostDiag, error) { + return diagHost(ctx, host, command), nil + }) + maps.Copy(results, collected) + } + } + + if unreachable := printDiagTable(results); unreachable > 0 { + return fmt.Errorf("%d of %d host(s) unreachable", unreachable, len(results)) + } + return nil +} + +// diagLocal runs the diagnostic command on the local machine (no SSH) and +// returns the parsed result. It is used for the driver host during a +// driver-routed check: the driver cannot SSH to itself through the generated +// config, and it is the very host the check is already running on, so the probe +// is run directly. The command reads the same clock it is measured against, so +// skewRTT naturally recovers a near-zero skew. +func diagLocal(alias, command string) hostDiag { + d := hostDiag{alias: alias} + ctx, cancel := context.WithTimeout(context.Background(), checkLocalTimeout) + defer cancel() + + before := time.Now() + out, err := exec.CommandContext(ctx, "bash", "-c", command).Output() + after := time.Now() + if err != nil { + d.errMsg = oneLine(err.Error()) + return d + } + + d.finishDiag(string(out), before, after) + return d +} + +// diagHost runs the diagnostic command on an already-connected host and returns +// the parsed result. Any command failure is captured in the returned hostDiag +// (reachable=false) rather than returned as an error. +func diagHost(ctx context.Context, host iago.Host, command string) hostDiag { + d := hostDiag{alias: host.Name()} + + before := time.Now() + out, shErr := iago.Output(ctx, host, command) + after := time.Now() + if shErr != nil { + d.errMsg = shErr.Error() + return d + } + + d.finishDiag(out, before, after) + return d +} + +// finishDiag records a successful probe on d: it parses the diagnostic output, +// estimates the clock skew and round-trip time from the probe timestamps, and +// extracts the allowed TCP counters. The raw /proc/net counter dump is appended +// after the KEY=VALUE lines by diagCommand, so both are parsed from the same +// output. +func (d *hostDiag) finishDiag(out string, before, after time.Time) { + d.reachable = true + d.diagFields = parseDiag(out) + d.skew, d.rtt = d.diagFields.skewRTT(before, after) + + tcp := parseAllowedCounters(out, checkTCPAllowlist) + d.retransSegs = tcp["Tcp.RetransSegs"] + d.outSegs = tcp["Tcp.OutSegs"] + d.synRetrans = tcp["TcpExt.TCPSynRetrans"] +} + +// skewRTT estimates the remote clock skew (remote minus local) and the network +// round-trip time from the four NTP timestamps of the diag exchange: the local +// times just before (t1) and after (t4) the SSH command, and the remote clock +// sampled at the command's start (t2, EPOCH) and end (t3, EPOCH_END). Following +// NTP, the offset is ((t2-t1)+(t3-t4))/2 and the round-trip delay is +// (t4-t1)-(t3-t2). Sampling the remote clock at both ends lets the delay exclude +// the time the diag script spent running on the host, so neither the skew nor the +// reported uncertainty (rtt/2) is inflated by how long the probe took — which on +// a LAN, where the script's tens of milliseconds dwarf the sub-millisecond +// network path, would otherwise dominate the estimate. Falls back to the +// single-sample midpoint estimate when only EPOCH is present (an older host), and +// to a zero skew with the raw wall-clock span when no epoch was read. +func (f diagFields) skewRTT(before, after time.Time) (skew, rtt time.Duration) { + if f.epoch <= 0 { + return 0, after.Sub(before) + } + t1, t4 := before.UnixNano(), after.UnixNano() + t2 := int64(f.epoch * float64(time.Second)) + if f.epochEnd <= 0 { + // Older host emitted only one epoch: use the local midpoint (biased by + // the on-host script duration, but better than reporting nothing). + mid := t1 + (t4-t1)/2 + return time.Duration(t2 - mid), after.Sub(before) + } + t3 := int64(f.epochEnd * float64(time.Second)) + offset := ((t2 - t1) + (t3 - t4)) / 2 + // Clock-rate differences or measurement noise can make the remote-measured + // duration exceed the local span; a negative round-trip is meaningless. + delay := max((t4-t1)-(t3-t2), 0) + return time.Duration(offset), time.Duration(delay) +} + +// diagCommand builds the best-effort diagnostic shell script run on each host. +// Every datum is emitted as a KEY=VALUE line consumed by parseDiag; failures of +// individual probes are swallowed so the script always exits 0 and one slow or +// missing tool does not fail the whole check. +func diagCommand(basePort int, prog remoteProgram, remoteRoot string) string { + ports := make([]string, numProbePorts) + for i := range ports { + ports[i] = strconv.Itoa(basePort + i) + } + portList := strings.Join(ports, " ") + + // EPOCH (first) and EPOCH_END (last) bracket the whole script with the remote + // clock, so skewRTT can subtract the time the script spent running on the host + // and avoid biasing the skew by up to half that duration — which dominates on + // a LAN, where the script's tens of milliseconds swamp the sub-millisecond + // network path. Note: %% escapes a literal % for Printf; date needs %s.%N. + return fmt.Sprintf(`echo "EPOCH=$(date +%%s.%%N 2>/dev/null)" +echo "HOST=$(hostname 2>/dev/null)" +echo "KERNEL=$(uname -sr 2>/dev/null)" +echo "CPUS=$(nproc 2>/dev/null)" +echo "LOAD=$(cut -d' ' -f1 /proc/loadavg 2>/dev/null)" +procs=$(pgrep -fc '%[1]s' 2>/dev/null) +echo "PROCS=${procs:-0}" +busy= +for p in %[2]s; do + if ss -ltnH 2>/dev/null | grep -qE ":$p([[:space:]]|$)"; then busy="$busy $p"; fi +done +echo "PORTSBUSY=$busy" +root=%[3]s +user=${USER:-$(id -un)} +ns="$root/sweep-$user" +storage=missing +[ -d "$root" ] && storage=readonly +[ -d "$root" ] && [ -w "$root" ] && storage=ok +free=$(df -hPk "$root" 2>/dev/null | awk 'NR==2 {print $4}') +stale=0 +runs=0 +if [ -d "$ns" ]; then + stale=$(find "$ns" -maxdepth 1 -type f \( -name '*.binpb' -o -name '*.cpuprofile' -o -name '*.memprofile' -o -name 'sweep-*' \) 2>/dev/null | wc -l | tr -d ' ') + runs=$(find "$ns" -mindepth 1 -maxdepth 1 -type d -name 'sweep-driver-*' 2>/dev/null | wc -l | tr -d ' ') +fi +echo "STORAGE=$storage" +echo "FREE=${free:--}" +echo "STALE=${stale:-0}" +echo "DRIVERRUNS=${runs:-0}" +%[4]s 2>/dev/null +echo "EPOCH_END=$(date +%%s.%%N 2>/dev/null)"`, prog.pgrep(), portList, iago.Quote(remoteRoot), tcpStatsCommand) +} + +// failureDiagCommand builds the host-snapshot script run on each host of a +// failed run. Unlike diagCommand (a compact KEY=VALUE probe parsed into a +// table), this emits free-form text appended verbatim to a per-run snapshot +// file for a human to read post-mortem. It captures the host environment (load, +// fd limits), any benchmark processes still running, and the socket state on the +// given ports: listeners, plus all TCP connections including TIME_WAIT entries, +// which survive ~60s after a peer closes and so reveal connection-refused +// failures even though the processes have already exited. Every probe swallows +// its own errors so the script always exits 0 and one missing tool does not +// abort the snapshot. +func failureDiagCommand(ports []string, prog remoteProgram) string { + portList := strings.Join(ports, " ") + return fmt.Sprintf(`echo "uptime: $(uptime 2>/dev/null)" +echo "loadavg: $(cat /proc/loadavg 2>/dev/null)" +echo "cpus: $(nproc 2>/dev/null)" +echo "fd_limit_soft: $(ulimit -Sn 2>/dev/null)" +echo "fd_limit_hard: $(ulimit -Hn 2>/dev/null)" +echo "--- benchmark processes ---" +pgrep -fa '%[1]s' 2>/dev/null || echo "(none)" +for p in %[2]s; do + echo "--- listeners on :$p ---" + ss -ltnp 2>/dev/null | grep -E ":$p([[:space:]]|$)" || echo "(none)" + echo "--- connections on :$p (established + time-wait) ---" + ss -tan 2>/dev/null | grep -E ":$p([[:space:]]|$)" || echo "(none)" +done`, prog.pgrep(), portList) +} + +// diagFields holds the parsed KEY=VALUE output of diagCommand. +type diagFields struct { + hostname string + kernel string + cpus string + load string + epoch float64 // remote clock (seconds) sampled at the script's start + epochEnd float64 // remote clock (seconds) sampled at the script's end + procs int + portsBusy []string + storage string + free string + stale int + driverRuns int +} + +// parseDiag parses the KEY=VALUE lines emitted by diagCommand. Unknown keys are +// ignored and missing keys leave their zero value, so partial output from an +// older or stripped-down host still yields a useful result. +func parseDiag(output string) diagFields { + var f diagFields + for line := range strings.Lines(output) { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok { + continue + } + switch key { + case "HOST": + f.hostname = value + case "KERNEL": + f.kernel = value + case "CPUS": + f.cpus = value + case "LOAD": + f.load = value + case "EPOCH": + f.epoch, _ = strconv.ParseFloat(value, 64) + case "EPOCH_END": + f.epochEnd, _ = strconv.ParseFloat(value, 64) + case "PROCS": + f.procs, _ = strconv.Atoi(value) + case "PORTSBUSY": + f.portsBusy = strings.Fields(value) + case "STORAGE": + f.storage = value + case "FREE": + f.free = value + case "STALE": + f.stale, _ = strconv.Atoi(value) + case "DRIVERRUNS": + f.driverRuns, _ = strconv.Atoi(value) + } + } + return f +} + +// printDiagTable renders the per-host diagnostics as an aligned table, sorted by +// alias, and prints a trailing summary line flagging any problems. +// printDiagTable prints the per-host diagnostic table and a summary line, +// and returns how many hosts were unreachable, so callers (e.g. checkHosts) +// can fail loudly instead of always reporting success regardless of what the +// table shows. +func printDiagTable(results map[string]hostDiag) (unreachable int) { + sorted := slices.SortedFunc(maps.Values(results), func(a, b hostDiag) int { + return compareHost(a.alias, b.alias) + }) + + tw := tabwriter.NewWriter(log.Writer(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "HOST\tSTATUS\tHOSTNAME\tKERNEL\tCPUS\tLOAD\tSKEW\tRETRANS%\tSYN-RETX\tPORTS BUSY\tLINGERING\tSTORAGE\tFREE\tSTALE\tRUNS") + + var withBusyPorts, withLingering, skewed, withStale, badStorage, driverRuns int + for _, d := range sorted { + if !d.reachable { + unreachable++ + fmt.Fprintf(tw, "%s\tUNREACHABLE\t%s\n", d.alias, oneLine(d.errMsg)) + continue + } + + ports := "-" + if len(d.portsBusy) > 0 { + ports = strings.Join(d.portsBusy, ",") + withBusyPorts++ + } + lingering := "-" + if d.procs > 0 { + lingering = strconv.Itoa(d.procs) + withLingering++ + } + if d.skew.Abs() > time.Second { + skewed++ + } + if d.stale > 0 { + withStale++ + } + if d.storage != "ok" { + badStorage++ + } + driverRuns += d.driverRuns + + retransPct, synRetx := formatRetrans(d.retransSegs, d.outSegs, d.synRetrans) + fmt.Fprintf(tw, "%s\tOK\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\n", + d.alias, d.hostname, d.kernel, d.cpus, d.load, formatSkew(d.skew, d.rtt), retransPct, synRetx, ports, lingering, + d.storage, d.free, d.stale, d.driverRuns) + } + tw.Flush() + + log.Printf("checked %d host(s): %d unreachable, %d with busy ports, %d with lingering processes, %d with clock skew > 1s", + len(results), unreachable, withBusyPorts, withLingering, skewed) + log.Printf("remote storage: %d invalid, %d host(s) with stale disposable sweep files; %d driver run directories (use -list for details)", + badStorage, withStale, driverRuns) + if withStale > 0 { + log.Printf("stale files are reported only; -check does not delete them") + } + return unreachable +} + +// formatRetrans renders the since-boot retransmit ratio (retransSegs/outSegs, as +// a percentage) and the setup-loss counter (synRetrans) for the -check table. It +// returns "-", "-" when outSegs is zero, i.e. the host reported no TCP counters, +// so a missing reading is not mistaken for a healthy 0.00%. +func formatRetrans(retransSegs, outSegs, synRetrans uint64) (retransPct, synRetx string) { + if outSegs == 0 { + return "-", "-" + } + pct := 100 * float64(retransSegs) / float64(outSegs) + return fmt.Sprintf("%.2f%%", pct), strconv.FormatUint(synRetrans, 10) +} + +// formatSkew renders a clock-skew estimate with its round-trip uncertainty, +// e.g. "+12ms (±3ms)". Skew is shown to the nearest millisecond because the +// estimate cannot be more precise than the SSH round-trip. +func formatSkew(skew, rtt time.Duration) string { + return fmt.Sprintf("%+dms (±%dms)", + skew.Round(time.Millisecond).Milliseconds(), + (rtt / 2).Round(time.Millisecond).Milliseconds()) +} + +// oneLine collapses s to a single line for tabular output. +func oneLine(s string) string { + return strings.Join(strings.Fields(s), " ") +} diff --git a/benchkit/cmd/sweep/diag_test.go b/benchkit/cmd/sweep/diag_test.go new file mode 100644 index 00000000..82bfbd49 --- /dev/null +++ b/benchkit/cmd/sweep/diag_test.go @@ -0,0 +1,347 @@ +package main + +import ( + "io" + "log" + "os/exec" + "slices" + "strings" + "testing" + "time" +) + +func TestParseDiag(t *testing.T) { + tests := []struct { + name string + output string + want diagFields + }{ + { + name: "FullOutput", + output: "EPOCH=1716800000.500000000\nHOST=bb1\nKERNEL=Linux 5.15.0\nCPUS=16\nLOAD=0.42\n" + + "PROCS=2\nPORTSBUSY= 9000 9001\nEPOCH_END=1716800000.560000000\n", + want: diagFields{ + hostname: "bb1", + kernel: "Linux 5.15.0", + cpus: "16", + load: "0.42", + epoch: 1716800000.5, + epochEnd: 1716800000.56, + procs: 2, + portsBusy: []string{"9000", "9001"}, + }, + }, + { + name: "NoBusyPortsNoProcs", + output: "HOST=bb2\nPROCS=0\nPORTSBUSY=\n", + want: diagFields{hostname: "bb2", procs: 0, portsBusy: nil}, + }, + { + name: "UnknownKeysIgnored", + output: "HOST=bb3\nMYSTERY=42\nCPUS=8\n", + want: diagFields{hostname: "bb3", cpus: "8"}, + }, + { + name: "MalformedLinesSkipped", + output: "garbage line without equals\nHOST=bb4\n\n", + want: diagFields{hostname: "bb4"}, + }, + { + name: "BadNumbersZeroed", + output: "PROCS=notanumber\nEPOCH=alsobad\n", + want: diagFields{procs: 0, epoch: 0}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseDiag(tt.output) + if got.hostname != tt.want.hostname || + got.kernel != tt.want.kernel || + got.cpus != tt.want.cpus || + got.load != tt.want.load || + got.epoch != tt.want.epoch || + got.epochEnd != tt.want.epochEnd || + got.procs != tt.want.procs || + !slices.Equal(got.portsBusy, tt.want.portsBusy) { + t.Errorf("parseDiag()\n got = %+v\nwant = %+v", got, tt.want) + } + }) + } +} + +func TestSkewRTT(t *testing.T) { + base := time.Unix(1716800000, 0) + tests := []struct { + name string + epoch, epochEnd float64 + before, after time.Time + wantSkew, wantRTT time.Duration + }{ + { + // Remote clock +500ms ahead; 2ms each network leg, 6ms on-host script. + // The remote reads (t2,t3) bracket the script symmetrically, so the NTP + // offset recovers 500ms and the delay recovers the 4ms round-trip, + // excluding the on-host time — unlike a single midpoint sample. + name: "RemoteAhead", + epoch: 1716800000.492, + epochEnd: 1716800000.498, + before: base.Add(-10 * time.Millisecond), + after: base, + wantSkew: 500 * time.Millisecond, + wantRTT: 4 * time.Millisecond, + }, + { + // Remote clock -250ms behind, same path shape. + name: "RemoteBehind", + epoch: 1716799999.742, + epochEnd: 1716799999.748, + before: base.Add(-10 * time.Millisecond), + after: base, + wantSkew: -250 * time.Millisecond, + wantRTT: 4 * time.Millisecond, + }, + { + // Only the start epoch present (older host): fall back to the biased + // midpoint estimate and report the raw wall-clock span as the rtt. + name: "SingleEpochFallback", + epoch: 1716800000.25, + epochEnd: 0, + before: base.Add(-4 * time.Millisecond), + after: base.Add(4 * time.Millisecond), + wantSkew: 250 * time.Millisecond, + wantRTT: 8 * time.Millisecond, + }, + { + name: "NoEpochIsZero", + epoch: 0, + epochEnd: 0, + before: base, + after: base.Add(20 * time.Millisecond), + wantSkew: 0, + wantRTT: 20 * time.Millisecond, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := diagFields{epoch: tt.epoch, epochEnd: tt.epochEnd} + // Round to the nearest millisecond: the float64 epochs carry sub-µs + // imprecision that formatSkew also rounds away. + gotSkew, gotRTT := f.skewRTT(tt.before, tt.after) + if gotSkew.Round(time.Millisecond) != tt.wantSkew || gotRTT.Round(time.Millisecond) != tt.wantRTT { + t.Errorf("skewRTT(%v, %v) = (%v, %v), want (%v, %v)", + tt.before, tt.after, gotSkew, gotRTT, tt.wantSkew, tt.wantRTT) + } + }) + } +} + +func TestDiagCommand(t *testing.T) { + prog := newRemoteProgram("") + cmd := diagCommand(9000, prog, "/local") + // The probe must cover numProbePorts consecutive ports from the base. + for _, want := range []string{"9000", "9001", "9002", "9003"} { + if !strings.Contains(cmd, want) { + t.Errorf("diagCommand(9000) missing port %q in:\n%s", want, cmd) + } + } + // The lingering-process probe must use the program's bracketed pgrep pattern. + if !strings.Contains(cmd, prog.pgrep()) { + t.Errorf("diagCommand(9000) missing pgrep pattern %q in:\n%s", prog.pgrep(), cmd) + } + // A literal %s.%N for date must survive Sprintf (no stray format verbs). + if !strings.Contains(cmd, "date +%s.%N") { + t.Errorf("diagCommand(9000) missing 'date +%%s.%%N' in:\n%s", cmd) + } + // The remote clock must be sampled at both ends, with EPOCH before EPOCH_END, + // so skewRTT can bracket the script's on-host duration (see skewRTT). + start, end := strings.Index(cmd, "EPOCH="), strings.Index(cmd, "EPOCH_END=") + if start < 0 || end < 0 || start >= end { + t.Errorf("diagCommand(9000) must read EPOCH before EPOCH_END; got indices %d, %d in:\n%s", start, end, cmd) + } + // The raw /proc counter dump must be appended so the check can report + // retransmit health from the same output. + if !strings.Contains(cmd, tcpStatsCommand) { + t.Errorf("diagCommand(9000) missing tcp stats command %q in:\n%s", tcpStatsCommand, cmd) + } + if strings.Contains(cmd, "%!") { + t.Errorf("diagCommand(9000) has a Printf formatting error:\n%s", cmd) + } +} + +// TestCheckTCPAllowlistFromDiagOutput verifies the check-specific counters are +// parsed from output that mixes the KEY=VALUE diag lines with the appended raw +// /proc counter dump, and that the KEY=VALUE lines are ignored by the parser. +func TestCheckTCPAllowlistFromDiagOutput(t *testing.T) { + out := "HOST=bb9\nKERNEL=Linux 5.15.0\nPORTSBUSY=\n" + procNetSample + got := parseAllowedCounters(out, checkTCPAllowlist) + want := map[string]uint64{ + "Tcp.RetransSegs": 24075589, + "Tcp.OutSegs": 1191999548, + "TcpExt.TCPSynRetrans": 555, + } + if len(got) != len(want) { + t.Fatalf("counters = %v, want %v", got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("counter[%q] = %d, want %d", k, got[k], v) + } + } +} + +func TestFormatRetrans(t *testing.T) { + tests := []struct { + name string + retransSegs, outSegs, synRetx uint64 + wantPct, wantSyn string + }{ + {name: "NoData", retransSegs: 0, outSegs: 0, synRetx: 0, wantPct: "-", wantSyn: "-"}, + {name: "Healthy", retransSegs: 1000, outSegs: 1_000_000, synRetx: 0, wantPct: "0.10%", wantSyn: "0"}, + {name: "Sick", retransSegs: 24075589, outSegs: 1191999548, synRetx: 555, wantPct: "2.02%", wantSyn: "555"}, + {name: "ZeroRetransRealData", retransSegs: 0, outSegs: 500, synRetx: 0, wantPct: "0.00%", wantSyn: "0"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPct, gotSyn := formatRetrans(tt.retransSegs, tt.outSegs, tt.synRetx) + if gotPct != tt.wantPct || gotSyn != tt.wantSyn { + t.Errorf("formatRetrans(%d, %d, %d) = (%q, %q), want (%q, %q)", + tt.retransSegs, tt.outSegs, tt.synRetx, gotPct, gotSyn, tt.wantPct, tt.wantSyn) + } + }) + } +} + +func TestFailureDiagCommand(t *testing.T) { + prog := newRemoteProgram("") + cmd := failureDiagCommand([]string{"9000", "9001"}, prog) + // Each node port must be probed for listeners and connections. + for _, want := range []string{"9000", "9001"} { + if !strings.Contains(cmd, want) { + t.Errorf("failureDiagCommand missing port %q in:\n%s", want, cmd) + } + } + // The process probe must use the program's bracketed pgrep pattern. + if !strings.Contains(cmd, prog.pgrep()) { + t.Errorf("failureDiagCommand missing pgrep pattern %q in:\n%s", prog.pgrep(), cmd) + } + // Socket state must cover both listeners and all connections (time-wait). + for _, want := range []string{"ss -ltnp", "ss -tan", "loadavg", "fd_limit_soft"} { + if !strings.Contains(cmd, want) { + t.Errorf("failureDiagCommand missing probe %q in:\n%s", want, cmd) + } + } + if strings.Contains(cmd, "%!") { + t.Errorf("failureDiagCommand has a Printf formatting error:\n%s", cmd) + } +} + +// TestDiagLocal runs the local self-probe path used for the driver host, +// exercising the same command shape diagCommand emits (KEY=VALUE lines followed +// by the raw /proc counter dump) so the parsed result matches an SSH probe. +func TestDiagLocal(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + t.Run("Reachable", func(t *testing.T) { + // A stand-in for diagCommand's output: identifying KEY=VALUE lines plus + // the /proc counter dump the check reads for retransmit health. The dump + // is emitted through a heredoc so its newlines survive into the output. + cmd := "echo HOST=driverhost\n" + + "echo KERNEL='Linux 6.8.0'\n" + + "echo CPUS=12\n" + + "echo LOAD=0.50\n" + + "echo PROCS=0\n" + + "echo PORTSBUSY=\n" + + "cat <<'PROCNET'\n" + procNetSample + "\nPROCNET\n" + d := diagLocal("bb1", cmd) + if !d.reachable { + t.Fatalf("diagLocal reachable = false, errMsg = %q", d.errMsg) + } + if d.alias != "bb1" || d.hostname != "driverhost" || d.cpus != "12" { + t.Errorf("diagLocal parsed = %+v", d.diagFields) + } + if d.outSegs != 1191999548 || d.retransSegs != 24075589 || d.synRetrans != 555 { + t.Errorf("diagLocal tcp counters = retrans %d out %d syn %d", + d.retransSegs, d.outSegs, d.synRetrans) + } + }) + t.Run("CommandFailure", func(t *testing.T) { + d := diagLocal("bb1", "exit 3") + if d.reachable { + t.Errorf("diagLocal reachable = true for a failing command") + } + if d.errMsg == "" { + t.Errorf("diagLocal errMsg empty for a failing command") + } + }) +} + +func TestFormatSkew(t *testing.T) { + tests := []struct { + name string + skew time.Duration + rtt time.Duration + want string + }{ + {name: "PositiveSkew", skew: 12 * time.Millisecond, rtt: 6 * time.Millisecond, want: "+12ms (±3ms)"}, + {name: "NegativeSkew", skew: -1500 * time.Millisecond, rtt: 20 * time.Millisecond, want: "-1500ms (±10ms)"}, + {name: "ZeroSkew", skew: 0, rtt: 0, want: "+0ms (±0ms)"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatSkew(tt.skew, tt.rtt); got != tt.want { + t.Errorf("formatSkew(%v, %v) = %q, want %q", tt.skew, tt.rtt, got, tt.want) + } + }) + } +} + +// TestPrintDiagTableReturnsUnreachableCount verifies that printDiagTable +// reports how many hosts were unreachable, the signal checkHosts uses to +// fail -check loudly instead of always reporting success regardless of the +// table's contents. +func TestPrintDiagTableReturnsUnreachableCount(t *testing.T) { + defer log.SetOutput(log.Writer()) + log.SetOutput(io.Discard) + + tests := []struct { + name string + results map[string]hostDiag + want int + }{ + {"AllReachable", map[string]hostDiag{ + "bb1": {alias: "bb1", reachable: true, diagFields: diagFields{storage: "ok"}}, + "bb2": {alias: "bb2", reachable: true, diagFields: diagFields{storage: "ok"}}, + }, 0}, + {"SomeUnreachable", map[string]hostDiag{ + "bb1": {alias: "bb1", reachable: true, diagFields: diagFields{storage: "ok"}}, + "bb2": {alias: "bb2", reachable: false, errMsg: "dial timeout"}, + "bb3": {alias: "bb3", reachable: false, errMsg: "connection refused"}, + }, 2}, + {"Empty", map[string]hostDiag{}, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := printDiagTable(tt.results); got != tt.want { + t.Errorf("printDiagTable() = %d, want %d", got, tt.want) + } + }) + } +} + +// TestCheckHostsFailsOnUnreachableHost verifies that -check's underlying +// checkHosts returns an error when any host is unreachable, instead of +// always returning nil regardless of what the table shows: -check exiting 0 +// on unreachable hosts meant scripted preflights (and the driver-routed +// check's own exit-status branch) could never gate on the check. +func TestCheckHostsFailsOnUnreachableHost(t *testing.T) { + defer log.SetOutput(log.Writer()) + log.SetOutput(io.Discard) + + // An alias with no matching SSH config entry fails to dial, landing in + // group.DialErrors rather than aborting the whole check. + err := checkHosts([]string{"no-such-sweep-test-host.invalid"}, "", 9000, newRemoteProgram(""), "", "/tmp") + if err == nil { + t.Error("checkHosts(unreachable host) = nil error, want an error") + } +} diff --git a/benchkit/cmd/sweep/dimensions.go b/benchkit/cmd/sweep/dimensions.go new file mode 100644 index 00000000..43dc81e5 --- /dev/null +++ b/benchkit/cmd/sweep/dimensions.go @@ -0,0 +1,157 @@ +package main + +import ( + "cmp" + "slices" + "strconv" + "strings" + + "github.com/relab/gorums/benchkit" +) + +func compareDimensions(a, b benchkit.Dimensions) int { + return cmp.Or( + cmp.Compare(a.Benchmark, b.Benchmark), + cmp.Compare(a.Nodes, b.Nodes), + cmp.Compare(a.Workers, b.Workers), + cmp.Compare(a.Payload, b.Payload), + cmp.Compare(a.Rate, b.Rate), + cmp.Compare(a.SendBuffer, b.SendBuffer), + cmp.Compare(a.RecvBuffer, b.RecvBuffer), + cmp.Compare(a.StreamMode, b.StreamMode), + ) +} + +func comparisonDimensions(d benchkit.Dimensions) benchkit.Dimensions { + d.StreamMode = "" + return d +} + +// loadScaleDimensions extracts the dimensions that drive a throughput-latency +// curve's scale (see tlIdent): payload and rate, plus the buffer capacities, +// which can shift peak latency by an order of magnitude (bufferbloat) without +// otherwise identifying the load. The dimensions in loads are cleared: a curve +// traces along them, so points differing only there belong to one curve. +func loadScaleDimensions(d benchkit.Dimensions, loads []string) benchkit.Dimensions { + scale := benchkit.Dimensions{ + Payload: d.Payload, Rate: d.Rate, + SendBuffer: d.SendBuffer, RecvBuffer: d.RecvBuffer, + } + if slices.Contains(loads, "rate") { + scale.Rate = 0 + } + return scale +} + +func nodeHealthDimensions(d benchkit.Dimensions) benchkit.Dimensions { + return benchkit.Dimensions{ + Benchmark: d.Benchmark, Nodes: d.Nodes, StreamMode: d.StreamMode, + } +} + +// dimensionSpec describes one sweep dimension: its CSV column name, the +// human-readable axis label a figure gives it, the short tag a compact +// configuration label uses (empty for the dimensions whose value speaks for +// itself), and how to read its value from a record. +type dimensionSpec struct { + name string + label string + tag string + value func(benchkit.Dimensions) string +} + +var dimensionSpecs = []dimensionSpec{ + {"benchmark", "Benchmark", "", func(d benchkit.Dimensions) string { return d.Benchmark }}, + {"nodes", "Nodes (N)", "N", func(d benchkit.Dimensions) string { return strconv.Itoa(d.Nodes) }}, + {"workers", "Workers", "W", func(d benchkit.Dimensions) string { return strconv.Itoa(d.Workers) }}, + {"payload", "Payload (bytes)", "P", func(d benchkit.Dimensions) string { return strconv.Itoa(d.Payload) }}, + {"rate", "Offered rate (ops/s per node)", "R", func(d benchkit.Dimensions) string { return strconv.Itoa(d.Rate) }}, + {"send_buffer", "Send queue capacity (requests)", "SB", func(d benchkit.Dimensions) string { return strconv.Itoa(d.SendBuffer) }}, + {"recv_buffer", "Receive queue capacity (messages)", "RB", func(d benchkit.Dimensions) string { return strconv.Itoa(d.RecvBuffer) }}, + {"stream_mode", "Stream mode", "", func(d benchkit.Dimensions) string { return d.StreamMode }}, +} + +// varyingDimensions returns the dimensions whose value differs across the given +// configurations. It is what a compact label must name to identify one of them: +// a label repeating what every configuration shares says nothing about which +// one it labels, and what they all share belongs in the report header instead. +func varyingDimensions(configs []benchkit.Dimensions) map[string]bool { + varying := make(map[string]bool, len(dimensionSpecs)) + for _, dim := range dimensionSpecs { + for _, config := range configs { + if dim.value(config) != dim.value(configs[0]) { + varying[dim.name] = true + break + } + } + } + return varying +} + +// configLabel names one configuration compactly, listing only the dimensions in +// varying: "N15 P16384 R1000 dedup". Tagged dimensions render as tag+value, the +// benchmark and stream mode as their bare value. An unset numeric dimension +// (value 0, the marker for "not swept") is left out. The label is empty when +// nothing varies, which leaves the caller's own heading to identify the run. +func configLabel(dims benchkit.Dimensions, varying map[string]bool) string { + var parts []string + for _, dim := range dimensionSpecs { + if !varying[dim.name] { + continue + } + value := dim.value(dims) + if value == "" || value == "0" { + continue + } + parts = append(parts, dim.tag+value) + } + return strings.Join(parts, " ") +} + +// dimensionValue returns one dimension's value as a string, or "" when name +// does not name a dimension — including the empty name a figure uses for an +// absent facet. +func dimensionValue(dims benchkit.Dimensions, name string) string { + dim, ok := findDimension(name) + if !ok { + return "" + } + return dim.value(dims) +} + +func findDimension(name string) (dimensionSpec, bool) { + for _, dim := range dimensionSpecs { + if dim.name == name { + return dim, true + } + } + return dimensionSpec{}, false +} + +func dimensionColumns(omit ...string) []string { + skip := make(map[string]bool, len(omit)) + for _, name := range omit { + skip[name] = true + } + var columns []string + for _, dim := range dimensionSpecs { + if !skip[dim.name] { + columns = append(columns, dim.name) + } + } + return columns +} + +func dimensionValues(dims benchkit.Dimensions, omit ...string) []string { + skip := make(map[string]bool, len(omit)) + for _, name := range omit { + skip[name] = true + } + var values []string + for _, dim := range dimensionSpecs { + if !skip[dim.name] { + values = append(values, dim.value(dims)) + } + } + return values +} diff --git a/benchkit/cmd/sweep/dimensions_test.go b/benchkit/cmd/sweep/dimensions_test.go new file mode 100644 index 00000000..a5e16fc1 --- /dev/null +++ b/benchkit/cmd/sweep/dimensions_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "testing" + + "github.com/relab/gorums/benchkit" +) + +func TestDimensionProjections(t *testing.T) { + full := benchkit.Dimensions{ + Benchmark: "Q", Nodes: 9, Workers: 8, Payload: 1024, Rate: 5000, + SendBuffer: 256, RecvBuffer: 16, StreamMode: "dual", + } + + comparison := comparisonDimensions(full) + wantComparison := full + wantComparison.StreamMode = "" + if comparison != wantComparison { + t.Errorf("comparisonDimensions = %+v, want %+v", comparison, wantComparison) + } + + if got, want := loadScaleDimensions(full, []string{"workers"}), (benchkit.Dimensions{ + Payload: 1024, Rate: 5000, SendBuffer: 256, RecvBuffer: 16, + }); got != want { + t.Errorf("loadScaleDimensions = %+v, want %+v", got, want) + } + // A curve traced along the offered rate must not band by it: its points + // differ only in the rate, so they belong to one curve and one band. + if got, want := loadScaleDimensions(full, []string{"rate"}), (benchkit.Dimensions{ + Payload: 1024, SendBuffer: 256, RecvBuffer: 16, + }); got != want { + t.Errorf("loadScaleDimensions with rate traced = %+v, want %+v", got, want) + } + if got, want := nodeHealthDimensions(full), (benchkit.Dimensions{ + Benchmark: "Q", Nodes: 9, StreamMode: "dual", + }); got != want { + t.Errorf("nodeHealthDimensions = %+v, want %+v", got, want) + } +} + +// TestConfigLabel verifies that a compact configuration label names only the +// dimensions that vary between the labeled configurations: what they all share +// identifies none of them and belongs in the report's experiment line, and an +// unset numeric dimension describes no part of the experiment at all. +func TestConfigLabel(t *testing.T) { + configs := []benchkit.Dimensions{ + {Benchmark: "Q", Nodes: 9, Workers: 32, Payload: 4096, Rate: 1000, SendBuffer: 4096, StreamMode: "dedup"}, + {Benchmark: "Q", Nodes: 15, Workers: 32, Payload: 16384, Rate: 1000, SendBuffer: 4096, StreamMode: "dual"}, + } + varying := varyingDimensions(configs) + for _, name := range []string{"nodes", "payload", "stream_mode"} { + if !varying[name] { + t.Errorf("varyingDimensions omits %q, which differs between the configurations", name) + } + } + for _, name := range []string{"benchmark", "workers", "rate", "send_buffer", "recv_buffer"} { + if varying[name] { + t.Errorf("varyingDimensions includes %q, which every configuration shares", name) + } + } + if got, want := configLabel(configs[1], varying), "N15 P16384 dual"; got != want { + t.Errorf("configLabel = %q, want %q", got, want) + } + // A single configuration varies in nothing, so it has no label of its own. + if got := configLabel(configs[0], varyingDimensions(configs[:1])); got != "" { + t.Errorf("configLabel of a lone configuration = %q, want empty", got) + } +} + +func TestExcludedByBufferDimension(t *testing.T) { + dims := benchkit.Dimensions{SendBuffer: 256, RecvBuffer: 0} + if !excludedByDim(map[string]map[string]bool{"send_buffer": {"256": true}}, dims) { + t.Error("send_buffer=256 did not exclude matching dimensions") + } + if !excludedByDim(map[string]map[string]bool{"recv_buffer": {"0": true}}, dims) { + t.Error("recv_buffer=0 did not exclude matching dimensions") + } + if excludedByDim(map[string]map[string]bool{"send_buffer": {"64": true}}, dims) { + t.Error("send_buffer=64 excluded non-matching dimensions") + } +} diff --git a/benchkit/cmd/sweep/driver.go b/benchkit/cmd/sweep/driver.go new file mode 100644 index 00000000..1020e786 --- /dev/null +++ b/benchkit/cmd/sweep/driver.go @@ -0,0 +1,1291 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/relab/iago" +) + +// The cluster-local driver feature lets the sweep orchestration run on a host +// inside the cluster instead of on a distant laptop. Without it, every per-run +// SSH round-trip and the one-time binary upload to each host cross the WAN from +// the laptop; driven from a cluster-local host, that traffic stays on the LAN +// and the benchmark binary crosses the WAN only once (laptop -> driver). +// +// Every operation that ships a binary or the generated SSH config to the driver +// — a full -driver run, a driver-routed -check, or the explain check — goes +// through the shared cache in driverCacheDir (see cachedUpload), so a laptop +// with nothing changed since the last upload pays no WAN transfer at all, no +// matter which of those entry points it uses. +// +// Two processes cooperate: +// +// - The launcher (this file, run on the laptop with -driver ) cross- +// builds sweep and the benchmark binary for linux/amd64, uploads whichever +// of them (plus the generated SSH config) changed since the last upload to +// the driver's binary cache over a single iago SSH connection, and re-execs +// sweep on the driver with -driven. It then streams the remote sweep's +// output and, on clean completion, downloads a compact plot-data export +// plus any failed-run result files. Raw successful result files stay on the +// driver until an explicit -collect archives them. +// - The driven sweep (-driven, run on the driver) is an ordinary sweep with a +// few laptop-only steps relaxed (no module-root requirement, no build, no +// replay script, no stale-binary warning); it does all the peer SSH itself. +// +// Authentication to the peers uses the laptop's SSH agent, forwarded to the +// driver when ForwardAgent is set in the SSH config for the driver alias. iago +// authenticates via SSH_AUTH_SOCK, so the forwarded agent is used transparently. +// iago dials the driver once at startup and reuses that connection for all +// upload, exec, and download operations, so the agent is only needed for the +// first few seconds: once the driver has dialed its peers, a laptop disconnect +// no longer affects the run, which is why the remote sweep is detached (setsid) +// and survives the control connection dropping. + +// resolveDriver resolves the -driver flag to a concrete host alias and computes +// the benchmark host pool. The driver host is always excluded from the +// benchmark pool when it appears in hosts, so the orchestrator does not perturb +// a co-located replica's measurements. A driver host outside hosts (a dedicated +// head node) leaves the pool unchanged. The sentinel "first" selects hosts[0]. +func resolveDriver(driverFlag string, hosts []string) (driver string, benchHosts []string, err error) { + if driverFlag == "" { + return "", hosts, nil + } + driver, err = resolveDriverHost(driverFlag, hosts) + if err != nil { + return "", nil, err + } + benchHosts = make([]string, 0, len(hosts)) + for _, h := range hosts { + if h != driver { + benchHosts = append(benchHosts, h) + } + } + if len(benchHosts) == 0 { + return "", nil, fmt.Errorf("no benchmark hosts left after excluding driver %q", driver) + } + return driver, benchHosts, nil +} + +// resolveDriverHost resolves the -driver flag to a concrete host alias. The +// sentinel "first" selects hosts[0]; any other value is the alias itself. Unlike +// resolveDriver it computes no benchmark pool, so it serves callers (such as the +// driver-routed explain check) that need only the driver host. +func resolveDriverHost(driverFlag string, hosts []string) (string, error) { + if driverFlag == "first" { + if len(hosts) == 0 { + return "", errors.New("-driver first: no hosts to choose from") + } + return hosts[0], nil + } + return driverFlag, nil +} + +// maxNodeCount returns the largest node count across the sweep, used to warn +// when excluding the driver leaves too few hosts for one-node-per-host runs. +func maxNodeCount(sc sweepConfig) int { + if len(sc.numNodes) == 0 { + return 0 + } + return slices.Max(sc.numNodes) +} + +// driverCacheDir is the persistent driver-side directory that holds the sweep +// and benchmark binaries and the generated SSH config, shared across every +// -driver entry point (a full run, a driver-routed -check, the explain check). +// Caching them here instead of inside each run's fresh, timestamped work +// directory means a laptop with nothing changed since the last upload pays no +// WAN transfer at all, regardless of which entry point it uses; see +// cachedUpload. It persists across runs until the driver reboots (or /tmp is +// cleared) and is never touched by a run's own cleanup, which only removes its +// own work directory. +const driverCacheName = "cache" + +// cachedUpload uploads localPath into the driver's binary cache under name, +// skipping the transfer when the driver already holds a file with the same +// content. Content identity is tracked by a SHA-256 marker file +// (cacheDir/..sha256) written after every successful upload, rather than +// by mtime or git state: a freshly built binary gets a new mtime on every build +// even when its bytes are unchanged (rsync's default quick check would then +// re-transfer it), and a hash needs no assumption that the working tree matches +// some git commit — so it is correct uniformly for a locally built binary, a +// user-supplied -binary override, and the generated SSH config alike. Returns +// the artifact's absolute path on the driver (cacheDir/name). +func cachedUpload(ctx context.Context, cfg *config, driver string, host iago.Host, cacheDir, localPath, name string, perm iago.Perm) (string, error) { + hash, err := fileSHA256(localPath) + if err != nil { + return "", fmt.Errorf("hash %s: %w", name, err) + } + remotePath := cacheDir + "/" + name + markerPath := cacheDir + "/." + name + ".sha256" + cached, _ := iago.Output(ctx, host, "cat "+iago.Quote(markerPath)+" 2>/dev/null || true") + if strings.TrimSpace(cached) == hash { + log.Printf("%s unchanged on %s; skipping upload", name, driver) + return remotePath, nil + } + log.Printf("uploading %s to %s via %s...", name, driver, cfg.transferMode) + if err := uploadDriverFile(ctx, cfg, driver, host, localPath, remotePath, perm); err != nil { + return "", fmt.Errorf("upload %s: %w", name, err) + } + if err := driverExec(ctx, host, "printf '%s' "+iago.Quote(hash)+" > "+iago.Quote(markerPath)); err != nil { + log.Printf("warning: record cache marker for %s on %s: %v", name, driver, err) + } + return remotePath, nil +} + +// fileSHA256 returns the hex-encoded SHA-256 digest of the file at path. +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// runDriver is the laptop-side launcher: build, ship, re-exec on the driver, +// stream, and collect. It connects only to the driver host (over iago using the +// user's SSH config), never to the peers. +func runDriver(cfg *config, hosts []string) error { + driver, benchHosts, err := resolveDriver(cfg.driver, hosts) + if err != nil { + return err + } + if maxN := maxNodeCount(cfg.sweep); len(benchHosts) < maxN { + log.Printf("warning: %d benchmark host(s) after excluding driver %s, but largest -n is %d; "+ + "nodes will be packed onto fewer hosts", len(benchHosts), driver, maxN) + } + if err := requireBenchkitModuleRoot(); err != nil { + return err + } + sha := gitHeadSHA() + if line := sweepEstimateLine(cfg.sweep, cfg.duration); line != "" { + log.Print(line) + } + + // Stage the binaries and SSH config in a local temp dir; they are uploaded + // to the driver and not needed afterward. + stage, err := os.MkdirTemp("", "sweep-driver-") + if err != nil { + return fmt.Errorf("staging dir: %w", err) + } + defer os.RemoveAll(stage) + + benchLocal := cfg.binaryPath + if benchLocal == "" { + benchLocal = filepath.Join(stage, "benchmark") + buildCmd := cfg.buildCmd + if buildCmd == "" { + buildCmd = os.Getenv("BENCHKIT_BUILD") + } + if err := buildBenchmark(benchLocal, buildCmd); err != nil { + return fmt.Errorf("build benchmark: %w", err) + } + } + sweepLocal := filepath.Join(stage, "sweep") + if err := buildSweepBinary(sweepLocal); err != nil { + return fmt.Errorf("build sweep: %w", err) + } + cfgLocal := filepath.Join(stage, "ssh.config") + if err := os.WriteFile(cfgLocal, []byte(generatedSSHConfig()), 0o644); err != nil { + return fmt.Errorf("write ssh config: %w", err) + } + + // Connect to the driver host once and reuse the connection for all operations. + driverGroup, err := dialDriverGroup(driver, cfg.sshConfig) + if err != nil { + return fmt.Errorf("connect to driver: %w", err) + } + defer driverGroup.Close() + host := driverGroup.Hosts[0] + ctx := context.Background() + namespace, err := ensureRemoteNamespace(ctx, host, cfg.remoteDir) + if err != nil { + return err + } + cacheDir := namespace + "/" + driverCacheName + base := filepath.Base(cfg.outDir) + "-" + time.Now().Format("20060102_150405") + wd := namespace + "/sweep-driver-" + base + log.Printf("driver: %s:%s", driver, wd) + log.Printf("benchmark hosts (%d): %s", len(benchHosts), strings.Join(benchHosts, ",")) + + if err := driverExec(ctx, host, "mkdir -p "+iago.Quote(wd)+" "+iago.Quote(cacheDir)); err != nil { + return fmt.Errorf("create remote work dir: %w", err) + } + localRunDir, err := filepath.Abs(cfg.outDir) + if err != nil { + return fmt.Errorf("resolve local run dir: %w", err) + } + state := lastRunState{ + Driver: driver, RemoteWorkDir: wd, RemoteNamespace: namespace, + Label: cfg.sweepLabel, LaunchedAt: time.Now(), LocalRunDir: localRunDir, + SSHConfig: cfg.sshConfig, TransferMode: cfg.transferMode, Collection: "pending", + } + if err := writeLastRunState(cfg.rootDir, state); err != nil { + return fmt.Errorf("record latest driver run: %w", err) + } + remoteMeta, err := json.Marshal(struct { + Label string `json:"label"` + LaunchedAt time.Time `json:"launched_at"` + }{Label: state.Label, LaunchedAt: state.LaunchedAt}) + if err != nil { + return fmt.Errorf("encode remote run metadata: %w", err) + } + if err := driverExec(ctx, host, "printf '%s\\n' "+iago.Quote(string(remoteMeta))+" > "+iago.Quote(wd+"/run.meta.json")); err != nil { + return fmt.Errorf("record remote run metadata: %w", err) + } + if _, err := writeReplayScript(cfg.outDir, os.Args); err != nil { + return fmt.Errorf("write replay script: %w", err) + } + if _, err := writeCollectScript(cfg.outDir, state); err != nil { + return fmt.Errorf("write collect script: %w", err) + } + for _, u := range []struct { + local string + name string + perm iago.Perm + }{ + {sweepLocal, "sweep", iago.NewPerm(0o755)}, + {benchLocal, "benchmark", iago.NewPerm(0o755)}, + {cfgLocal, "ssh.config", iago.NewPerm(0o644)}, + } { + if _, err := cachedUpload(ctx, cfg, driver, host, cacheDir, u.local, u.name, u.perm); err != nil { + return err + } + } + + sweepCmd := remoteSweepCommand(cfg, wd, cacheDir, strings.Join(benchHosts, ","), sha) + llmEnv := driverLLMEnv(cfg) + + if cfg.detach { + log.Printf("starting remote sweep on %s (detached)", driver) + runErr := iago.Shell{ + Command: "bash -s", + Stdin: strings.NewReader(detachBootstrapScript(wd, sweepCmd, llmEnv, cfg.fdLimit)), + Stdout: os.Stderr, + Stderr: os.Stderr, + }.Apply(ctx, host) + if runErr != nil { + return fmt.Errorf("start detached run on %s: %w", driver, runErr) + } + log.Printf("[driver] waiting for the run to dial its peers (up to %s) before declaring it safe to disconnect...", detachReadyWindow) + if err := awaitDetachedStartup(ctx, host, driver, wd); err != nil { + return err + } + log.Printf("detached sweep started on %s:%s; laptop is free to disconnect", driver, wd) + log.Printf("collect results with:") + log.Printf(" ./cmd/sweep/sweep -collect -outdir %s", cfg.rootDir) + log.Printf("collect a snapshot before completion with:") + log.Printf(" ./cmd/sweep/sweep -collect-now -outdir %s", cfg.rootDir) + return nil + } + + log.Printf("starting remote sweep on %s (output streamed below)", driver) + log.Printf("if the connection drops, the run continues; reconnect and collect with:") + log.Printf(" ./cmd/sweep/sweep -driver %s -collect %s -outdir %s", driver, wd, cfg.rootDir) + runErr := iago.Shell{ + Command: "bash -s", + Stdin: strings.NewReader(bootstrapScript(wd, sweepCmd, llmEnv, cfg.fdLimit)), + Stdout: os.Stderr, + Stderr: os.Stderr, + }.Apply(ctx, host) + if !finishedRemotely(runErr) { + return fmt.Errorf("connection to driver ended before the sweep finished: %w\n"+ + "the run may still be in progress on %s\n"+ + "reconnect with: ./cmd/sweep/sweep -driver %s -collect %s -outdir %s", + runErr, driver, driver, wd, cfg.rootDir) + } + return collectDriverResults(cfg, host, wd) +} + +// runDriverCollect checks a driver run once and downloads it only when finished, +// unless -collect-now requested a best-effort active snapshot. It needs no SSH +// agent because the driver has long since dialed its peers. +func runDriverCollect(cfg *config) error { + if cfg.driver == "" { + return errors.New("-collect requires -driver ") + } + driverGroup, err := dialDriverGroup(cfg.driver, cfg.sshConfig) + if err != nil { + return fmt.Errorf("connect to driver: %w", err) + } + defer driverGroup.Close() + host := driverGroup.Hosts[0] + + driver, wd := cfg.driver, cfg.collect + collectNow := false + if cfg.collectNow != "" { + wd = cfg.collectNow + collectNow = true + } + finished, err := iago.FileExists(context.Background(), host, wd+"/exit.code") + if err != nil { + return fmt.Errorf("check run status: %w", err) + } + if !finished && !collectNow { + // A missing exit.code means the run is still active only if its + // directory still exists at all; it also reads this way once the + // directory has been archived (collectDriverFullResults removes it + // with rm -rf) or if the path was mistyped. Consult the saved run + // state, which records exactly this outcome, so the message reflects + // what actually happened instead of always guessing "still active". + if state, err := readLastRunState(cfg.rootDir); err == nil && state.RemoteWorkDir == wd && state.Collection != "" && state.Collection != "pending" { + return fmt.Errorf("run %s:%s was already collected (%s); nothing left to collect", driver, wd, state.Collection) + } + if exists, err := iago.DirExists(context.Background(), host, wd); err == nil && !exists { + return fmt.Errorf("run directory %s:%s not found; it may have already been archived, or the path is incorrect", driver, wd) + } + return fmt.Errorf("run %s:%s is still active; retry -collect after it finishes or use -collect-now for a snapshot", driver, wd) + } + if !finished { + log.Printf("collecting an in-progress snapshot from %s:%s; remote data will be retained", driver, wd) + err := collectDriverSnapshot(cfg, host, wd) + if err == nil { + updateLastRunCollection(cfg.rootDir, wd, "snapshot") + } + return err + } + log.Printf("collecting finished driver run %s:%s", driver, wd) + return collectDriverResults(cfg, host, wd) +} + +// runDriverExplainCheck verifies the triage LLM from the driver, so a laptop +// behind the firewall can confirm it reaches the UiS Ollama server (which only +// the driver can reach). It builds the sweep binary, ships it to a temp dir on +// the driver, runs sweep -explain-check there with the forwarded API key, and +// removes the temp dir. Unlike a full driven sweep it needs no benchmark binary, +// generated SSH config, or peer connections. +func runDriverExplainCheck(cfg *config, hosts []string) error { + driver, err := resolveDriverHost(cfg.driver, hosts) + if err != nil { + return err + } + // The key is validated on the laptop in parseFlags, so driverLLMEnv returns a + // non-empty export here; guard anyway since the check cannot run without it. + llmEnv := driverLLMEnv(cfg) + if llmEnv == "" { + return fmt.Errorf("%s must be set on the laptop to forward to the driver", providerKeyEnv(cfg.explainProvider)) + } + + stage, err := os.MkdirTemp("", "sweep-explain-check-") + if err != nil { + return fmt.Errorf("staging dir: %w", err) + } + defer os.RemoveAll(stage) + sweepLocal := filepath.Join(stage, "sweep") + if err := buildSweepBinary(sweepLocal); err != nil { + return fmt.Errorf("build sweep: %w", err) + } + + // No agent forwarding: the check does no peer SSH, so requesting it only + // yields a noisy "forwarding request denied" when the driver refuses. + driverGroup, err := iago.NewSSHGroup([]string{driver}, cfg.sshConfig, iago.FailFast(), iago.KeepAlive(driverKeepAlive)) + if err != nil { + return fmt.Errorf("connect to driver: %w", err) + } + defer driverGroup.Close() + host := driverGroup.Hosts[0] + ctx := context.Background() + namespace, err := ensureRemoteNamespace(ctx, host, cfg.remoteDir) + if err != nil { + return err + } + wd := namespace + "/sweep-explain-check-" + time.Now().Format("20060102_150405") + + if err := driverExec(ctx, host, "mkdir -p "+iago.Quote(wd)); err != nil { + return fmt.Errorf("create remote work dir: %w", err) + } + log.Printf("uploading sweep binary to %s via %s...", driver, cfg.transferMode) + if err := uploadDriverFile(ctx, cfg, driver, host, sweepLocal, wd+"/sweep", iago.NewPerm(0o755)); err != nil { + return fmt.Errorf("upload sweep: %w", err) + } + + log.Printf("running explain check on %s (output streamed below)", driver) + runErr := iago.Shell{ + Command: "bash -s", + Stdin: strings.NewReader(explainCheckScript(wd, llmEnv, cfg.explainProvider, cfg.explainModel)), + Stdout: os.Stderr, + Stderr: os.Stderr, + }.Apply(ctx, host) + + // Remove the temp dir regardless of the check result. + if err := driverExec(ctx, host, "rm -rf "+iago.Quote(wd)); err != nil { + log.Printf("warning: remote cleanup of %s:%s: %v", driver, wd, err) + } + + if runErr == nil { + return nil + } + if exitErr, ok := errors.AsType[iago.ExitStatus](runErr); ok { + return fmt.Errorf("explain check failed on %s (exit status %d)", driver, exitErr.ExitStatus()) + } + return fmt.Errorf("connection to driver %s ended before the check finished: %w", driver, runErr) +} + +// explainCheckScript exports the forwarded API key and runs the uploaded sweep +// binary's -explain-check on the driver. The key is exported (not passed as a +// flag or printed) so it stays out of the argv and the streamed console, the +// same discipline bootstrapScript uses for a full sweep. The trailing "exit" +// is essential: bash -s reads its script from stdin, and iago closes that pipe +// only after the command returns, so without an explicit exit a successful run +// blocks waiting for stdin EOF (set -e already exits on the failure path). +func explainCheckScript(wd, llmEnv, provider, model string) string { + return fmt.Sprintf(`set -e +export %s +cd %s +./sweep -explain-check -explain-provider %s -explain-model %s +exit 0 +`, llmEnv, iago.Quote(wd), iago.Quote(provider), iago.Quote(model)) +} + +// runDriverCheck runs the -check host diagnostics from the driver rather than +// the laptop, so the reported clock skew is measured against the driver's +// LAN-local clock (the same vantage the benchmark's own ClockSync uses) instead +// of the laptop's, whose WAN round-trip to the cluster otherwise dominates both +// the skew estimate and its uncertainty. It ships the sweep binary plus the +// generated SSH config to the driver's binary cache (skipping either that is +// already up to date, per cachedUpload), runs "sweep -check" there over the +// same hosts (the driver reaches its peers over the LAN using the forwarded +// agent, and probes itself locally — a host cannot SSH to itself — so the +// driver row reads a near-zero skew), and streams the table back. Unlike a full +// driven sweep it uploads no benchmark binary and drives no measurement. +func runDriverCheck(cfg *config, hosts []string) error { + driver, err := resolveDriverHost(cfg.driver, hosts) + if err != nil { + return err + } + + // Forward the agent: unlike the explain check, the driver-side check SSHes to + // every peer, authenticating with the laptop's forwarded key. + driverGroup, err := dialDriverGroup(driver, cfg.sshConfig) + if err != nil { + return fmt.Errorf("connect to driver: %w", err) + } + defer driverGroup.Close() + host := driverGroup.Hosts[0] + ctx := context.Background() + namespace, err := ensureRemoteNamespace(ctx, host, cfg.remoteDir) + if err != nil { + return err + } + cacheDir := namespace + "/" + driverCacheName + + if err := driverExec(ctx, host, "mkdir -p "+iago.Quote(cacheDir)); err != nil { + return fmt.Errorf("create remote cache dir: %w", err) + } + + stage, err := os.MkdirTemp("", "sweep-driver-check-") + if err != nil { + return fmt.Errorf("staging dir: %w", err) + } + defer os.RemoveAll(stage) + sweepLocal := filepath.Join(stage, "sweep") + if err := buildSweepBinary(sweepLocal); err != nil { + return fmt.Errorf("build sweep: %w", err) + } + cfgLocal := filepath.Join(stage, "ssh.config") + if err := os.WriteFile(cfgLocal, []byte(generatedSSHConfig()), 0o644); err != nil { + return fmt.Errorf("write ssh config: %w", err) + } + + if _, err := cachedUpload(ctx, cfg, driver, host, cacheDir, sweepLocal, "sweep", iago.NewPerm(0o755)); err != nil { + return err + } + if _, err := cachedUpload(ctx, cfg, driver, host, cacheDir, cfgLocal, "ssh.config", iago.NewPerm(0o644)); err != nil { + return err + } + + log.Printf("running check on %s over %d host(s) (output streamed below)", driver, len(hosts)) + runErr := iago.Shell{ + Command: "bash -s", + Stdin: strings.NewReader(driverCheckScript(cacheDir, strings.Join(hosts, ","), cfg.port, cfg.binaryPath, driver, cfg.remoteDir)), + Stdout: os.Stderr, + Stderr: os.Stderr, + }.Apply(ctx, host) + + if runErr == nil { + return nil + } + if exitErr, ok := errors.AsType[iago.ExitStatus](runErr); ok { + return fmt.Errorf("check failed on %s (exit status %d)", driver, exitErr.ExitStatus()) + } + return fmt.Errorf("connection to driver %s ended before the check finished: %w", driver, runErr) +} + +// driverCheckScript runs the cached sweep binary's -check on the driver over the +// given hosts, using the cached SSH config so the driver reaches its peers the +// same way a driven sweep does. cacheDir is the driver's binary cache +// (driverCacheDir), where cachedUpload placed both files. When binary is set (a +// foreign protocol run), it is forwarded so the lingering-process probe greps +// for the matching program name; otherwise the driver-side default matches the +// benchmark the sweep deploys. self is the driver's own alias, forwarded as +// -self-host so the check probes it locally instead of SSHing to itself (which +// fails on the loopback self-connection). Like explainCheckScript it ends with +// an explicit exit so bash -s does not block waiting for stdin EOF after a +// successful check. +func driverCheckScript(cacheDir, hostsCSV string, port int, binary, self, remoteDir string) string { + bin := "" + if binary != "" { + bin = " -binary " + iago.Quote(binary) + } + return fmt.Sprintf(`set -e +cd %s +./sweep -check -hosts %s -config %s/ssh.config -port %d -self-host %s -remote-dir %s%s +exit 0 +`, iago.Quote(cacheDir), iago.Quote(hostsCSV), iago.Quote(cacheDir), port, iago.Quote(self), iago.Quote(remoteDir), bin) +} + +// finishedRemotely reports whether the remote bootstrap/collect script ran to +// completion. A nil error or an SSH exit error (the remote process exited, even +// non-zero for failed runs) both mean "finished"; any other error type is a +// transport failure and the detached run may still be going. +func finishedRemotely(runErr error) bool { + if runErr == nil { + return true + } + if exitErr, ok := errors.AsType[iago.ExitStatus](runErr); ok { + log.Printf("remote sweep finished with non-zero status %d; collecting results", exitErr.ExitStatus()) + return true + } + return false +} + +// collectMode selects what a -collect downloads from the driver; see +// chooseCollectMode. +type collectMode int + +const ( + collectCompact collectMode = iota // first collection: compact plot data only + collectFull // compact already collected: full raw archive + cleanup + collectSalvage // sweep aborted before exporting: partial output as-is +) + +// chooseCollectMode picks the collection strategy: a prior compact collection +// (the marker) means this collect archives the full raw results; otherwise +// the compact transfer is downloaded when the driven sweep produced one; a +// missing compact transfer means the sweep died before its export step (e.g. +// a netcheck abort or a mid-sweep crash), so whatever partial output exists +// is salvaged instead of failing on the absent directory. +func chooseCollectMode(compactMarked, compactExists bool) collectMode { + switch { + case compactMarked: + return collectFull + case compactExists: + return collectCompact + default: + return collectSalvage + } +} + +// collectDriverResults downloads driver results into cfg.outDir. The first +// successful collection downloads only the compact transfer directory produced +// by the driven sweep and leaves the raw driver work dir in place. Once that +// compact collection is marked, a later -collect downloads the full raw archive +// and removes the remote work dir. A run that aborted before exporting any +// compact transfer has its partial output salvaged instead. +func collectDriverResults(cfg *config, host iago.Host, wd string) error { + // The driven sweep nests its results in a label subdirectory under wd/out + // (for example wd/out/e1-coord-nscale). Descend into that run directory so + // its contents land directly in cfg.outDir rather than one level too deep. + remoteOut := wd + "/out" + runDir, err := remoteRunDir(context.Background(), host, remoteOut) + if err != nil { + return fmt.Errorf("locate remote run dir under %s:%s: %w", host.Name(), remoteOut, err) + } + marked, err := iago.FileExists(context.Background(), host, driverCompactMarkerPath(wd)) + if err != nil { + return fmt.Errorf("check compact collection marker: %w", err) + } + compactExists, err := iago.DirExists(context.Background(), host, remoteOut+"/"+runDir+"/"+compactTransferDir) + if err != nil { + return fmt.Errorf("check compact transfer dir: %w", err) + } + switch chooseCollectMode(marked, compactExists) { + case collectFull: + return collectDriverFullResults(cfg, host, wd, remoteOut, runDir) + case collectCompact: + return collectDriverCompactResults(cfg, host, wd, remoteOut, runDir) + default: + return collectDriverSalvage(cfg, host, wd, remoteOut, runDir) + } +} + +// collectDriverSalvage downloads a driven sweep's partial output when it died +// before exporting a compact transfer (a netcheck abort, a setup failure, or +// a mid-sweep crash): whatever the run directory holds — sweep.log, per-run +// logs, manifests, and any raw result files — is downloaded as-is and binary +// results are converted for inspection. The remote work dir is retained so +// the aborted run can still be examined in place. +func collectDriverSalvage(cfg *config, host iago.Host, wd, remoteOut, runDir string) error { + remoteDir := remoteOut + "/" + runDir + log.Printf("no compact transfer on %s — the sweep aborted before exporting results; salvaging partial output", host.Name()) + if err := downloadDriverDir(cfg, host, remoteDir, cfg.outDir); err != nil { + return fmt.Errorf("download partial output: %w (output remains on %s:%s)", err, host.Name(), wd) + } + if n, err := convertDirBinaryResults(cfg.outDir); err != nil { + log.Printf("warning: convert downloaded results: %v", err) + } else if n > 0 { + log.Printf("converted %d binary result file(s) to protojson", n) + } + log.Printf("partial driver output collected — see %s for why the sweep aborted", displayPath(filepath.Join(cfg.outDir, "sweep.log"))) + updateLastRunCollection(cfg.rootDir, wd, "recoverable") + printProblemManifests(cfg.outDir) + log.Printf("driver work dir retained on %s:%s for inspection; to discard it:", host.Name(), wd) + log.Printf(" %s", driverCleanupCommand(host.Name(), cfg.sshConfig, wd)) + return nil +} + +func collectDriverSnapshot(cfg *config, host iago.Host, wd string) error { + remoteOut := wd + "/out" + runDir, err := remoteRunDir(context.Background(), host, remoteOut) + if err != nil { + return fmt.Errorf("the active run has not created a collectible output directory yet: %w", err) + } + snapshotDir := filepath.Join(cfg.outDir, "snapshot") + if err := os.MkdirAll(snapshotDir, 0o755); err != nil { + return err + } + if err := downloadDriverDir(cfg, host, remoteOut+"/"+runDir, snapshotDir); err != nil { + return fmt.Errorf("download active snapshot: %w", err) + } + if n, err := convertDirBinaryResults(snapshotDir); err != nil { + log.Printf("warning: convert snapshot results: %v", err) + } else if n > 0 { + log.Printf("converted %d snapshot result file(s) to protojson", n) + } + log.Printf("active snapshot collected in %s; remote run retained", displayPath(snapshotDir)) + return nil +} + +func collectDriverCompactResults(cfg *config, host iago.Host, wd, remoteOut, runDir string) error { + remoteCompact := remoteOut + "/" + runDir + "/" + compactTransferDir + log.Printf("downloading compact driver results via %s...", cfg.transferMode) + if err := downloadDriverDir(cfg, host, remoteCompact, cfg.outDir); err != nil { + return fmt.Errorf("download compact results: %w (raw results remain on %s:%s)", err, host.Name(), wd) + } + if err := os.WriteFile(filepath.Join(cfg.outDir, compactMarker), []byte(wd+"\n"), 0o644); err != nil { + log.Printf("warning: local compact marker: %v", err) + } + if err := driverExec(context.Background(), host, "touch "+iago.Quote(driverCompactMarkerPath(wd))); err != nil { + return fmt.Errorf("mark compact collection: %w (raw results remain on %s:%s)", err, host.Name(), wd) + } + log.Printf("driver sweep complete — compact results in %s", displayPath(cfg.outDir)) + log.Printf("sweep log (from driver): %s", displayPath(filepath.Join(cfg.outDir, "sweep.log"))) + printProblemManifests(cfg.outDir) + log.Printf("raw .binpb results retained on %s:%s", host.Name(), wd) + updateLastRunCollection(cfg.rootDir, wd, "compact") + log.Printf("to archive raw results and remove the driver work dir:") + log.Printf(" ./cmd/sweep/sweep -driver %s -collect %s -outdir %s", host.Name(), wd, cfg.rootDir) + log.Printf("to discard raw driver results without archiving:") + log.Printf(" %s", driverCleanupCommand(host.Name(), cfg.sshConfig, wd)) + autoReport(cfg) + return nil +} + +func collectDriverFullResults(cfg *config, host iago.Host, wd, remoteOut, runDir string) error { + log.Printf("compact results were already collected; downloading full raw archive via %s...", cfg.transferMode) + remoteDir := remoteOut + "/" + runDir + if err := downloadDriverDir(cfg, host, remoteDir, cfg.outDir); err != nil { + return fmt.Errorf("download raw archive: %w (results remain on %s:%s)", err, host.Name(), wd) + } + if n, err := convertDirBinaryResults(cfg.outDir); err != nil { + log.Printf("warning: convert downloaded results: %v", err) + } else { + log.Printf("converted %d binary result file(s) to protojson", n) + } + if err := driverExec(context.Background(), host, "rm -rf "+iago.Quote(wd)); err != nil { + log.Printf("warning: remote cleanup of %s:%s: %v", host.Name(), wd, err) + } + log.Printf("driver raw archive collected — results in %s", displayPath(cfg.outDir)) + updateLastRunCollection(cfg.rootDir, wd, "archived") + log.Printf("sweep log (from driver): %s", displayPath(filepath.Join(cfg.outDir, "sweep.log"))) + printProblemManifests(cfg.outDir) + autoReport(cfg) + return nil +} + +func downloadDriverDir(cfg *config, host iago.Host, remoteDir, localDir string) error { + var downloadErr error + if cfg.transferMode == "sftp" { + downloadErr = driverDownloadDir(context.Background(), host, remoteDir, localDir) + } else { + downloadErr = rsyncDownloadDir(host.Name(), cfg.sshConfig, remoteDir, localDir) + } + return downloadErr +} + +// uploadDriverFile copies localPath to remotePath on the driver, using the +// transfer backend selected by cfg.transferMode. It is the upload counterpart +// of downloadDriverDir. +func uploadDriverFile(ctx context.Context, cfg *config, driver string, host iago.Host, localPath, remotePath string, perm iago.Perm) error { + if cfg.transferMode == "sftp" { + return iago.UploadFile(ctx, host, localPath, remotePath, perm) + } + return rsyncUploadFile(driver, cfg.sshConfig, localPath, remotePath) +} + +func driverCompactMarkerPath(wd string) string { + return wd + "/" + compactMarker +} + +// readyMarkerName is the file the driven sweep touches once it has dialed its +// peers; a -detach launcher watches driverReadyMarkerPath for it to know the +// forwarded agent is no longer needed. +const readyMarkerName = "peers.dialed" + +func driverReadyMarkerPath(wd string) string { + return wd + "/" + readyMarkerName +} + +// detachReadyWindow bounds how long -detach waits for a freshly started run +// to finish dialing its peers before it gives up waiting for confirmation. +// The driven sweep needs the laptop's forwarded SSH agent only for that +// one-time dial, then touches the ready marker; until it does, the laptop +// must stay connected or the dial fails with "no valid authentication +// methods". The window must comfortably exceed the driven sweep's setup time +// (an -explain preflight plus dialing every peer), so it is generous: it is a +// backstop, not the common case, since the marker normally appears in +// seconds. +const ( + detachReadyWindow = 3 * time.Minute + detachReadyPoll = 2 * time.Second +) + +// awaitDetachedStartup waits, right after a detached run starts, until the +// driven sweep either finishes dialing its peers (the ready marker) or dies +// during setup (exit.code), via pollDetachedStartup. A poll error (e.g. a +// transient control-connection hiccup, exactly the kind of session flakiness +// this check exists to route around) must not end the wait early — only a +// definitive answer, or the deadline, may. +func awaitDetachedStartup(ctx context.Context, host iago.Host, driver, wd string) error { + dialed := func() (bool, error) { return iago.FileExists(ctx, host, driverReadyMarkerPath(wd)) } + crashed := func() (bool, error) { return iago.FileExists(ctx, host, wd+"/exit.code") } + tail := func() string { + out, err := iago.Output(ctx, host, "tail -n 40 "+iago.Quote(wd+"/console.log")) + if err != nil { + return "" + } + return out + } + return pollDetachedStartup(dialed, crashed, tail, driver, wd, detachReadyWindow, detachReadyPoll) +} + +// pollDetachedStartup implements the retry and decision logic for +// awaitDetachedStartup against injectable dialed/crashed/tail functions, so +// the timing and error-handling behavior are unit testable without a live SSH +// connection. Each poll checks dialed first: once the ready marker exists the +// run is past its one-time peer dial and safe to leave, even if it has since +// also exited. Only exit.code WITHOUT the marker means the run died before +// dialing — a setup failure — for which it returns a descriptive error with +// the console tail. A poll error on either check only records lastErr and is +// retried; a single hiccup must never make a crashed run look healthy. Once +// the deadline passes with neither answer it logs a warning and returns nil, +// since a stuck poll says nothing about the detached run's own health. +func pollDetachedStartup(dialed, crashed func() (bool, error), tail func() string, driver, wd string, window, interval time.Duration) error { + deadline := time.Now().Add(window) + var lastErr error + for { + if ok, err := dialed(); err != nil { + lastErr = err + } else if ok { + log.Printf("detached run on %s finished dialing its peers; forwarded agent no longer needed", driver) + return nil + } else if ec, err := crashed(); err != nil { + // Only reachable when the marker is absent: an exit.code here means + // the run exited before completing its peer dial. + lastErr = err + } else if ec { + return errors.New(earlyExitMessage(driver, wd, tail())) + } + if time.Now().After(deadline) { + log.Printf("warning: could not confirm detached run on %s dialed its peers within %s (last error: %v); proceeding without confirmation — verify with -collect", driver, window, lastErr) + return nil + } + time.Sleep(interval) + } +} + +// earlyExitMessage formats the error returned when a detached run's exit.code +// appears before its peer-dial marker. consoleTail is the tail of the remote +// console.log, or "" if it could not be read. +func earlyExitMessage(driver, wd, consoleTail string) string { + msg := fmt.Sprintf("detached run on %s exited before dialing its peers; "+ + "this is not a normal completion — check that SSH agent forwarding to %s is working "+ + "and that the driven sweep's flags are valid; raw results remain on %s:%s for inspection", + driver, driver, driver, wd) + if consoleTail != "" { + msg += "\nlast console output:\n" + consoleTail + } + return msg +} + +func driverCleanupCommand(driver, sshConfig, wd string) string { + args := []string{"ssh"} + if sshConfig != "" { + args = append(args, "-F", sshConfig) + } + args = append(args, driver, "rm -rf "+iago.Quote(wd)) + return strings.Join(args, " ") +} + +// manifestPathsWithStatus returns the downloaded manifests with the given +// status, so the launcher can surface failed and degraded runs on the laptop +// console (the driver's own listing names the driver-side paths, which do not +// exist locally). +func manifestPathsWithStatus(outDir, status string) []string { + matches, err := filepath.Glob(filepath.Join(outDir, "*"+manifestSuffix)) + if err != nil { + return nil + } + var paths []string + for _, p := range matches { + if manifestStatus(p) == status { + paths = append(paths, p) + } + } + return paths +} + +// printProblemManifests lists the failed and degraded run manifests under +// outDir, each with its diagnostic artifact paths. +func printProblemManifests(outDir string) { + if failed := manifestPathsWithStatus(outDir, runStatusFailed); len(failed) > 0 { + log.Printf("failed run manifests (%d):", len(failed)) + for _, p := range failed { + printFailedRunArtifacts(outDir, p) + } + } + if degraded := manifestPathsWithStatus(outDir, runStatusDegraded); len(degraded) > 0 { + log.Printf("degraded run manifests (%d):", len(degraded)) + for _, p := range degraded { + printFailedRunArtifacts(outDir, p) + } + } +} + +// printFailedRunArtifacts logs a failed run's manifest path plus, when present, +// its node log and failure snapshot, so a reader can jump straight to the +// diagnostic files without hunting for them under logSubdir. +func printFailedRunArtifacts(outDir, manifestFile string) { + log.Printf(" %s", displayPath(manifestFile)) + base := strings.TrimSuffix(filepath.Base(manifestFile), manifestSuffix) + if p := runLogPath(outDir, base); fileExists(p) { + log.Printf(" node log: %s", displayPath(p)) + } + if p := snapshotPath(outDir, base); fileExists(p) { + log.Printf(" host snapshot: %s", displayPath(p)) + } +} + +// fileExists reports whether path names a regular, readable file. +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// remoteSweepCommand builds the shell command that runs the driven sweep on the +// driver. It rebuilds the flags from cfg (not os.Args) so the expanded -test +// values are forwarded and the driver-only flags are rewritten to remote paths. +// binDir is where the sweep and benchmark binaries and the SSH config were +// cached by cachedUpload (see driverCacheDir); it is independent of wd, the +// run's own work directory, so a cached binary is never tied to one run's +// timestamped directory and can be reused by the next. +func remoteSweepCommand(cfg *config, wd, binDir, hostsCSV, sha string) string { + args := []string{ + "-driven", + "-git-sha=" + sha, + "-hosts=" + hostsCSV, + "-binary=" + binDir + "/benchmark", + "-config=" + binDir + "/ssh.config", + "-outdir=" + wd + "/out", + "-remote-dir=" + cfg.remoteDir, + "-port=" + strconv.Itoa(cfg.port), + "-duration=" + cfg.duration.String(), + "-trim=" + cfg.trim.String(), + "-sweep=" + cfg.sweepLabel, + "-n=" + joinInts(cfg.sweep.numNodes), + "-workers=" + joinInts(cfg.sweep.workers), + "-payload=" + joinInts(cfg.sweep.payloads), + "-rate=" + joinInts(cfg.sweep.rates), + "-benchmarks=" + strings.Join(cfg.sweep.benchmarks, ","), + "-reps=" + strconv.Itoa(cfg.sweep.reps), + "-degraded-below=" + strconv.FormatFloat(cfg.degradedBelow, 'g', -1, 64), + "-degraded-above=" + strconv.FormatFloat(cfg.degradedAbove, 'g', -1, 64), + "-degraded-latency-below=" + strconv.FormatFloat(cfg.degradedLatencyBelow, 'g', -1, 64), + "-netcheck=" + strconv.FormatBool(cfg.netcheck), + "-fd-limit=" + strconv.Itoa(cfg.fdLimit), + } + // The buffer axes default to empty, and the list flags reject an empty + // value, so forward them only when the launcher set them. + if len(cfg.sweep.sendBuffers) > 0 { + args = append(args, "-send-buffer="+joinInts(cfg.sweep.sendBuffers)) + } + if len(cfg.sweep.recvBuffers) > 0 { + args = append(args, "-recv-buffer="+joinInts(cfg.sweep.recvBuffers)) + } + if cfg.detach { + // In -detach the launcher leaves as soon as the driven sweep signals it + // has dialed its peers, so it must know where to look for that marker. + args = append(args, "-ready-marker="+driverReadyMarkerPath(wd)) + } + if cfg.verbose { + args = append(args, "-verbose") + } + if cfg.interval != "" { + args = append(args, "-interval="+cfg.interval) + } + if cfg.statsMode != "" { + args = append(args, "-stats-mode="+cfg.statsMode) + } + if cfg.rateStep > 0 { + args = append(args, "-rate-step="+strconv.Itoa(cfg.rateStep)) + } + if cfg.rateStepMax > 0 { + args = append(args, "-rate-step-max="+strconv.Itoa(cfg.rateStepMax)) + } + if nonDefaultStreamModes(cfg.sweep.streamModes) { + args = append(args, "-stream-mode="+strings.Join(cfg.sweep.streamModes, ",")) + } + if cfg.extraArgs != "" { + args = append(args, "-extra-args="+cfg.extraArgs) + } + switch { + case cfg.pgo: + args = append(args, "-pgo") + case cfg.collectProfiles: + args = append(args, "-collect-profiles") + } + // Triage runs on the driver; the laptop forwards the model choice. + // The API key is not a flag (see runDriver). + if cfg.explain { + args = append(args, "-explain", "-explain-provider="+cfg.explainProvider, "-explain-model="+cfg.explainModel) + if cfg.explainMaxLog > 0 { + args = append(args, "-explain-max-log="+strconv.Itoa(cfg.explainMaxLog)) + } + } + parts := make([]string, 0, len(args)+1) + parts = append(parts, iago.Quote(binDir+"/sweep")) + for _, a := range args { + parts = append(parts, iago.Quote(a)) + } + return strings.Join(parts, " ") +} + +// driverLLMEnv returns the NAME=value assignment that forwards the LLM API key +// to the driver so it can triage (-explain) or run a connectivity check +// (-explain-check) there, or "" when neither is set or the key is unset on the +// laptop. The value is shell-quoted for safe export; the remote scripts keep it +// out of run.sh, the argv, and console.log. parseFlags already fails fast when +// the key is unset, so in practice this returns a non-empty export whenever +// triage or a check is requested; the empty fallback is defensive. +func driverLLMEnv(cfg *config) string { + if !cfg.explain && !cfg.explainCheck { + return "" + } + name := providerKeyEnv(cfg.explainProvider) + val := os.Getenv(name) + if val == "" { + log.Printf("warning: -explain set but %s is not set; driver-side triage will be skipped", name) + return "" + } + return name + "=" + iago.Quote(val) +} + +// generatedSSHConfig is the minimal SSH config uploaded to the driver. iago +// reads "StrictHostKeyChecking no" as InsecureIgnoreHostKey, so the driver +// needs no seeded known_hosts; authentication is by the forwarded agent, and +// peer aliases resolve through the driver's own resolver (plain "ssh bbN"). +func generatedSSHConfig() string { + return `# Generated by 'sweep -driver': minimal config for driver->peer SSH. +# Auth uses the SSH agent forwarded from the laptop (ForwardAgent in the user's +# SSH config). Host-key checking is disabled because the cluster LAN is trusted +# and the driver has no seeded known_hosts for its peers; peer aliases resolve +# via the driver's own resolver. +Host * + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + LogLevel ERROR +` +} + +// bootstrapPreamble creates the remote work dir, writes run.sh, and starts +// the sweep detached (setsid) so it survives a laptop disconnect. The sweep +// command goes through a run.sh written by a quoted heredoc so its embedded +// quotes are not reinterpreted by the bootstrap shell. It is shared by +// bootstrapScript and detachBootstrapScript, which differ only in what they +// do once the detached sweep has started. +// +// envExport, when non-empty, is a NAME=value assignment (e.g. the LLM API key +// for -explain) exported into the bootstrap shell and inherited by the detached +// sweep. It is deliberately kept out of run.sh, the sweep argv, and console.log +// so the secret never lands on disk or in the streamed log. +func bootstrapPreamble(wd, sweepCmd, envExport string, fdLimit int) string { + export := "" + if envExport != "" { + export = "export " + envExport + "\n" + } + // Raise the driver-side sweep's own soft open-file limit before exec; the + // orchestrator holds a connection to every peer, so it hits the same 1024 + // default a node does. The nodes it launches get their own ulimit via + // buildNodeCmd. + ulimit := "" + if stmt := fdLimitStmt(fdLimit); stmt != "" { + ulimit = stmt + "\n" + } + return fmt.Sprintf(`set -e +%sWD=%s +mkdir -p "$WD" +cd "$WD" +rm -f exit.code +# Create the log before tailing so the follower never races the writer; the +# detached sweep appends to the same file. +: > console.log +cat > run.sh <<'SWEEP_EOF' +#!/bin/sh +%sexec %s +SWEEP_EOF +chmod +x run.sh +setsid sh -c './run.sh >> console.log 2>&1; echo $? > exit.code' /dev/null 2>&1 & +echo $! > run.pid +echo "[driver] detached sweep started in $WD" +`, export, iago.Quote(wd), ulimit, sweepCmd) +} + +// bootstrapScript starts the detached sweep via bootstrapPreamble, then tails +// its console log until the run records an exit code. +func bootstrapScript(wd, sweepCmd, envExport string, fdLimit int) string { + return bootstrapPreamble(wd, sweepCmd, envExport, fdLimit) + `tail -n +1 -F console.log & +TAILPID=$! +while [ ! -f exit.code ]; do sleep 1; done +sleep 1 +kill "$TAILPID" 2>/dev/null || true +EC=$(cat exit.code) +echo "[driver] sweep exited with status $EC" +exit "$EC" +` +} + +// detachBootstrapScript starts the detached sweep via bootstrapPreamble and +// returns immediately, without tailing its console log or waiting for it to +// finish. It is used by -detach so the launcher can confirm the run started +// and exit, leaving collection for a later -collect. +func detachBootstrapScript(wd, sweepCmd, envExport string, fdLimit int) string { + return bootstrapPreamble(wd, sweepCmd, envExport, fdLimit) + "exit 0\n" +} + +// driverKeepAlive is how often the launcher pings the driver control channel. +// The channel can sit quiet for the length of a single benchmark run while the +// driver streams nothing, and crypto/ssh does not honor ServerAliveInterval, so +// without these pings a NAT or firewall idle timeout could silently drop the +// connection mid-sweep. +const driverKeepAlive = 30 * time.Second + +// dialDriverGroup connects to the driver host using the user's SSH config. +// Agent forwarding is always requested (equivalent to ssh -A) so that the +// driven sweep on the driver can authenticate to its peers using the +// laptop's keys via the forwarded agent. Keepalives hold the long-lived +// control channel open across quiet stretches of a run. +func dialDriverGroup(driver, sshConfigFile string) (iago.Group, error) { + return iago.NewSSHGroup([]string{driver}, sshConfigFile, + iago.FailFast(), iago.ForwardAgent(), iago.KeepAlive(driverKeepAlive)) +} + +// remoteRunDir returns the name of the run directory the driven sweep created +// under remoteOut (the driver's wd/out). The driven sweep always nests its +// results in a single label subdirectory, whose name the launcher cannot +// reconstruct on reconnect; listing the directory is robust in every case. +func remoteRunDir(ctx context.Context, host iago.Host, remoteOut string) (string, error) { + out, err := iago.Output(ctx, host, "ls -1 "+iago.Quote(remoteOut)) + if err != nil { + return "", err + } + for line := range strings.SplitSeq(out, "\n") { + if name := strings.TrimSpace(line); name != "" { + return name, nil + } + } + return "", fmt.Errorf("no run directory found") +} + +// driverExec runs a shell command on host, streaming output to the console. +func driverExec(ctx context.Context, host iago.Host, command string) error { + return iago.Shell{ + Command: command, + Stdout: os.Stderr, + Stderr: os.Stderr, + }.Apply(ctx, host) +} + +// driverDownloadDir downloads the contents of remoteDir on host into localDir +// via SFTP, streaming each file to disk so the result set never has to fit in +// RAM. A pre-scan computes the total byte count so that progress is shown as +// bytes transferred / total (percentage). +func driverDownloadDir(ctx context.Context, host iago.Host, remoteDir, localDir string) error { + absLocal, err := filepath.Abs(localDir) + if err != nil { + return err + } + if err := os.MkdirAll(absLocal, 0o755); err != nil { + return err + } + src, err := iago.NewPathFromAbs(remoteDir) + if err != nil { + return err + } + dest, err := iago.NewPathFromAbs(absLocal) + if err != nil { + return err + } + + dl := iago.DownloadDir{Src: src, Dest: dest} + total, _ := dl.Size(ctx, host) + + var done int64 + dl.Progress = func(n int64) { + done += n + var line string + if total > 0 { + line = fmt.Sprintf("downloading: %s / %s (%.0f%%)", + formatSize(done), formatSize(total), float64(done)/float64(total)*100) + } else { + line = fmt.Sprintf("downloading: %s", formatSize(done)) + } + fmt.Fprintf(os.Stderr, "\r%-72s", line) + } + + log.Printf("downloading results from %s:%s via SFTP...", host.Name(), remoteDir) + if err := dl.Apply(ctx, host); err != nil { + fmt.Fprintln(os.Stderr) + return err + } + fmt.Fprintln(os.Stderr) + log.Printf("downloaded results from %s:%s → %s", host.Name(), remoteDir, displayPath(localDir)) + return nil +} + +// rsyncArgs returns the common rsync arguments for both uploads and downloads: +// -a preserves layout and permissions, -z compresses on the wire, -s sends +// remote paths through the rsync protocol instead of the login shell, --partial +// keeps a partial file so an interrupted transfer resumes on retry, and +// --progress prints per-file progress. A non-empty sshConfig is forwarded to +// ssh via -e so rsync uses the same config the launcher used. +func rsyncArgs(sshConfig string) []string { + args := []string{"-azs", "--partial", "--progress"} + if sshConfig != "" { + args = append(args, "-e", "ssh -F "+iago.Quote(sshConfig)) + } + return args +} + +// rsyncRemoteSpec returns an rsync remote source or destination. The path is +// deliberately not shell-quoted: -s makes rsync send it through the protocol. +func rsyncRemoteSpec(driver, path string) string { + return driver + ":" + path +} + +// requireRsyncSecludedArgs verifies that the local rsync supports -s. The +// system openrsync shipped by macOS identifies as rsync 2.6.9-compatible and +// does not implement this option; using it would otherwise produce a confusing +// transfer failure after the driver connection has already been established. +func requireRsyncSecludedArgs() error { + cmd := exec.Command("rsync", "-s", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("rsync with -s/--secluded-args is required (install rsync >=3.2.4): %w", err) + } + return nil +} + +// rsyncUploadFile uploads localPath to driver:remotePath via rsync over SSH. +// Permissions are preserved from the local file (-a flag). On repeated runs +// rsync sends only changed blocks, so a rebuild that touches few bytes is fast. +func rsyncUploadFile(driver, sshConfig, localPath, remotePath string) error { + if err := requireRsyncSecludedArgs(); err != nil { + return err + } + args := append(rsyncArgs(sshConfig), localPath, rsyncRemoteSpec(driver, remotePath)) + cmd := exec.Command("rsync", args...) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("rsync: %w", err) + } + return nil +} + +// rsyncDownloadDir downloads the contents of remoteDir on driver into localDir +// via rsync over SSH. Unlike the SFTP-based driverDownloadDir, rsync streams +// to disk, compresses on the wire, prints live progress, and resumes a partial +// transfer on a second attempt — the result set crosses the WAN once, robustly. +func rsyncDownloadDir(driver, sshConfig, remoteDir, localDir string) error { + if err := requireRsyncSecludedArgs(); err != nil { + return err + } + if err := os.MkdirAll(localDir, 0o755); err != nil { + return err + } + // Trailing slashes copy directory contents, not the directory itself. + args := append(rsyncArgs(sshConfig), rsyncRemoteSpec(driver, remoteDir+"/"), localDir+"/") + log.Printf("downloading results from %s:%s via rsync (compressed, resumable)...", driver, remoteDir) + cmd := exec.Command("rsync", args...) + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("rsync: %w", err) + } + log.Printf("downloaded results from %s:%s → %s", driver, remoteDir, displayPath(localDir)) + return nil +} + +// formatSize formats n as a human-readable byte count (e.g. "12.3 MiB"). +func formatSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} + +// buildSweepBinary cross-compiles sweep itself for linux/amd64. Run from the +// benchkit module root, which is where its own package path resolves. +func buildSweepBinary(outputPath string) error { + if err := requireBenchkitModuleRoot(); err != nil { + return err + } + abs, err := filepath.Abs(outputPath) + if err != nil { + return err + } + log.Printf("building sweep for linux/amd64 → %s", abs) + return runCrossBuild(exec.Command("go", "build", "-o", abs, "./cmd/sweep")) +} + +// joinInts formats an int slice as a comma-separated string for a sweep flag. +func joinInts(xs []int) string { + s := make([]string, len(xs)) + for i, x := range xs { + s[i] = strconv.Itoa(x) + } + return strings.Join(s, ",") +} diff --git a/benchkit/cmd/sweep/driver_test.go b/benchkit/cmd/sweep/driver_test.go new file mode 100644 index 00000000..0cf79592 --- /dev/null +++ b/benchkit/cmd/sweep/driver_test.go @@ -0,0 +1,651 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +// mockExitStatus simulates an SSH exit error for TestFinishedRemotely. +type mockExitStatus struct{ code int } + +func (e *mockExitStatus) Error() string { return fmt.Sprintf("exit status %d", e.code) } +func (e *mockExitStatus) ExitStatus() int { return e.code } + +func TestResolveDriver(t *testing.T) { + tests := []struct { + name string + driver string + hosts []string + wantDriver string + wantBench []string + wantErr bool + }{ + {name: "off", driver: "", hosts: []string{"bb1", "bb2"}, wantDriver: "", wantBench: []string{"bb1", "bb2"}}, + {name: "first", driver: "first", hosts: []string{"bb1", "bb2", "bb3"}, wantDriver: "bb1", wantBench: []string{"bb2", "bb3"}}, + {name: "explicit in hosts", driver: "bb2", hosts: []string{"bb1", "bb2", "bb3"}, wantDriver: "bb2", wantBench: []string{"bb1", "bb3"}}, + {name: "explicit outside hosts", driver: "driver", hosts: []string{"bb1", "bb2"}, wantDriver: "driver", wantBench: []string{"bb1", "bb2"}}, + {name: "first with no hosts", driver: "first", hosts: nil, wantErr: true}, + {name: "drains pool", driver: "bb1", hosts: []string{"bb1"}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + driver, bench, err := resolveDriver(tt.driver, tt.hosts) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if driver != tt.wantDriver { + t.Errorf("driver = %q, want %q", driver, tt.wantDriver) + } + if !slices.Equal(bench, tt.wantBench) { + t.Errorf("bench = %v, want %v", bench, tt.wantBench) + } + }) + } +} + +// TestResolveDriverHost verifies that the host-only resolver selects hosts[0] +// for "first", returns an explicit alias unchanged (even outside hosts), and +// rejects "first" with no hosts. Unlike resolveDriver it never drains a pool, +// since the explain check needs only the driver host. +func TestResolveDriverHost(t *testing.T) { + tests := []struct { + name string + driver string + hosts []string + want string + wantErr bool + }{ + {name: "first", driver: "first", hosts: []string{"bb1", "bb2"}, want: "bb1"}, + {name: "explicit", driver: "bb2", hosts: []string{"bb1", "bb2"}, want: "bb2"}, + {name: "explicit single host", driver: "bb1", hosts: []string{"bb1"}, want: "bb1"}, + {name: "outside hosts", driver: "driver", hosts: []string{"bb1"}, want: "driver"}, + {name: "first with no hosts", driver: "first", hosts: nil, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveDriverHost(tt.driver, tt.hosts) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("driver = %q, want %q", got, tt.want) + } + }) + } +} + +// TestExplainCheckScript verifies the remote check script exports the forwarded +// key and invokes -explain-check with the provider and model, so the key reaches +// the binary without appearing in its argv. +func TestExplainCheckScript(t *testing.T) { + script := explainCheckScript("/tmp/sweep-explain-check-x", "OLLAMA_API_KEY='secret'", "local", "llama3.3") + for _, want := range []string{ + "export OLLAMA_API_KEY='secret'", + "cd '/tmp/sweep-explain-check-x'", + "-explain-check", + "-explain-provider 'local'", + "-explain-model 'llama3.3'", + } { + if !strings.Contains(script, want) { + t.Errorf("script missing %q:\n%s", want, script) + } + } + if strings.Contains(script, "secret -explain-check") { + t.Error("key must not be passed on the command line") + } + // bash -s reads its script from stdin, which iago closes only after the + // command returns; without an explicit exit, a successful check blocks + // waiting for stdin EOF. The trailing exit must be present. + if !strings.Contains(script, "\nexit 0") { + t.Errorf("script must end with an explicit exit to avoid a stdin-EOF hang:\n%s", script) + } +} + +func TestDriverCheckScript(t *testing.T) { + script := driverCheckScript("/tmp/sweep-driver-check-x", "bb1,bb2,bb3", 9000, "", "bb1", "/local") + for _, want := range []string{ + "cd '/tmp/sweep-driver-check-x'", + "-check", + "-hosts 'bb1,bb2,bb3'", + "-config '/tmp/sweep-driver-check-x'/ssh.config", + "-port 9000", + // The driver forwards its own alias so the check probes it locally + // instead of SSHing to itself (which fails the loopback handshake). + "-self-host 'bb1'", + "-remote-dir '/local'", + } { + if !strings.Contains(script, want) { + t.Errorf("script missing %q:\n%s", want, script) + } + } + // With no -binary, the driver-side default matches the deployed benchmark; + // the flag must be omitted rather than passed empty. + if strings.Contains(script, "-binary") { + t.Errorf("script must omit -binary when none is set:\n%s", script) + } + // bash -s reads its script from stdin, which iago closes only after the + // command returns; without an explicit exit, a successful check blocks + // waiting for stdin EOF. The trailing exit must be present. + if !strings.Contains(script, "\nexit 0") { + t.Errorf("script must end with an explicit exit to avoid a stdin-EOF hang:\n%s", script) + } +} + +func TestDriverCheckScriptBinary(t *testing.T) { + script := driverCheckScript("/tmp/wd", "bb1", 9000, "./cmd/otherproto/bench", "bb1", "/tmp") + if !strings.Contains(script, "-binary './cmd/otherproto/bench'") { + t.Errorf("script must forward -binary when set:\n%s", script) + } +} + +func TestRemoteSweepCommand(t *testing.T) { + cfg := &config{ + port: 9000, + duration: 10 * time.Second, + trim: time.Second, + sweepLabel: "e1", + verbose: true, + interval: "250ms", + statsMode: "hdr", + rateStep: 1000, + rateStepMax: 8000, + extraArgs: "-fault-kill-after=5s", + pgo: true, + degradedBelow: 0.5, + netcheck: true, + sweep: sweepConfig{ + numNodes: []int{3, 5, 9}, + workers: []int{1, 4}, + payloads: []int{0}, + rates: []int{0}, + benchmarks: []string{"SymmetricQuorumCall", "QuorumCall"}, + streamModes: []string{"dual", "dedup"}, + reps: 3, + }, + } + cmd := remoteSweepCommand(cfg, "/tmp/sweep-driver-e1-x", "/tmp/sweep-driver-cache", "bb2,bb3", "deadbeef") + + mustContain := []string{ + // The sweep executable, -binary, and -config come from binDir (the + // driver's persistent binary cache), decoupled from wd (the run's own, + // timestamped work directory used only for -outdir and the ready marker). + "/tmp/sweep-driver-cache/sweep'", + "'-driven'", + "'-git-sha=deadbeef'", + "'-hosts=bb2,bb3'", + "'-binary=/tmp/sweep-driver-cache/benchmark'", + "'-config=/tmp/sweep-driver-cache/ssh.config'", + "'-outdir=/tmp/sweep-driver-e1-x/out'", + "'-port=9000'", + "'-duration=10s'", + "'-trim=1s'", + "'-sweep=e1'", + "'-n=3,5,9'", + "'-workers=1,4'", + "'-payload=0'", + "'-rate=0'", + "'-benchmarks=SymmetricQuorumCall,QuorumCall'", + "'-stream-mode=dual,dedup'", + "'-reps=3'", + "'-verbose'", + "'-interval=250ms'", + "'-stats-mode=hdr'", + "'-rate-step=1000'", + "'-rate-step-max=8000'", + "'-extra-args=-fault-kill-after=5s'", + "'-pgo'", + "'-degraded-below=0.5'", + "'-netcheck=true'", + } + for _, want := range mustContain { + if !strings.Contains(cmd, want) { + t.Errorf("command missing %q\ngot: %s", want, cmd) + } + } + // -driver/-collect/-test/-build must never reach the driven sweep. + for _, bad := range []string{"-driver=", "-collect", "-test", "-build", "-collect-profiles"} { + if strings.Contains(cmd, bad) { + t.Errorf("command should not contain %q\ngot: %s", bad, cmd) + } + } +} + +func TestRemoteSweepCommandProfilesWithoutPGO(t *testing.T) { + cfg := &config{ + port: 9000, duration: time.Second, sweepLabel: "p", collectProfiles: true, + sweep: sweepConfig{numNodes: []int{3}, workers: []int{1}, payloads: []int{0}, rates: []int{0}, benchmarks: []string{"QuorumCall"}, reps: 1}, + } + cmd := remoteSweepCommand(cfg, "/tmp/wd", "/tmp/wd", "bb1", "") + if !strings.Contains(cmd, "'-collect-profiles'") { + t.Errorf("expected -collect-profiles\ngot: %s", cmd) + } + if strings.Contains(cmd, "'-pgo'") { + t.Errorf("did not expect -pgo\ngot: %s", cmd) + } +} + +func TestRemoteSweepCommandOmitsDefaultStreamMode(t *testing.T) { + cfg := &config{ + port: 9000, duration: time.Second, sweepLabel: "p", + sweep: sweepConfig{ + numNodes: []int{3}, workers: []int{1}, payloads: []int{0}, + rates: []int{0}, benchmarks: []string{"QuorumCall"}, streamModes: []string{"dual"}, reps: 1, + }, + } + cmd := remoteSweepCommand(cfg, "/tmp/wd", "/tmp/wd", "bb1", "") + if strings.Contains(cmd, "-stream-mode") { + t.Errorf("default-only stream mode should be omitted\ngot: %s", cmd) + } +} + +func TestRemoteSweepCommandExplain(t *testing.T) { + cfg := &config{ + port: 9000, duration: time.Second, sweepLabel: "e1", + explain: true, explainProvider: "local", explainModel: "llama3.3", explainMaxLog: 4096, + sweep: sweepConfig{numNodes: []int{3}, workers: []int{1}, payloads: []int{0}, rates: []int{0}, benchmarks: []string{"QuorumCall"}, reps: 1}, + } + cmd := remoteSweepCommand(cfg, "/tmp/wd", "/tmp/wd", "bb1", "") + for _, want := range []string{"'-explain'", "'-explain-provider=local'", "'-explain-model=llama3.3'", "'-explain-max-log=4096'"} { + if !strings.Contains(cmd, want) { + t.Errorf("command missing %q\ngot: %s", want, cmd) + } + } + // The API key is never forwarded as a flag. + if strings.Contains(cmd, "API_KEY") || strings.Contains(cmd, "-explain-key") { + t.Errorf("command leaked an API key\ngot: %s", cmd) + } + + // Without -explain, none of the flags appear. + cfg.explain = false + if got := remoteSweepCommand(cfg, "/tmp/wd", "/tmp/wd", "bb1", ""); strings.Contains(got, "-explain") { + t.Errorf("command should omit -explain when disabled\ngot: %s", got) + } +} + +func TestRemoteSweepCommandDetach(t *testing.T) { + cfg := &config{ + port: 9000, duration: time.Second, sweepLabel: "e1", detach: true, + sweep: sweepConfig{numNodes: []int{3}, workers: []int{1}, payloads: []int{0}, rates: []int{0}, benchmarks: []string{"QuorumCall"}, reps: 1}, + } + // The ready marker lives under wd (the run's own work directory), not binDir + // (the driver's persistent binary cache), since it is per-run state. + if got := remoteSweepCommand(cfg, "/tmp/sweep-driver-e1-x", "/tmp/sweep-driver-cache", "bb1", ""); !strings.Contains(got, "'-ready-marker=/tmp/sweep-driver-e1-x/"+readyMarkerName+"'") { + t.Errorf("detached command missing -ready-marker\ngot: %s", got) + } + // Without -detach the launcher streams and collects itself, so the driven + // sweep needs no ready marker. + cfg.detach = false + if got := remoteSweepCommand(cfg, "/tmp/wd", "/tmp/wd", "bb1", ""); strings.Contains(got, "-ready-marker") { + t.Errorf("non-detached command should omit -ready-marker\ngot: %s", got) + } +} + +func TestDriverLLMEnv(t *testing.T) { + t.Run("disabled", func(t *testing.T) { + if got := driverLLMEnv(&config{explain: false}); got != "" { + t.Errorf("driverLLMEnv = %q, want empty when -explain off", got) + } + }) + t.Run("key present", func(t *testing.T) { + t.Setenv(envLocalKey, "3secret3") + got := driverLLMEnv(&config{explain: true, explainProvider: providerLocal}) + if got != envLocalKey+"='3secret3'" { + t.Errorf("driverLLMEnv = %q, want %s='3secret3'", got, envLocalKey) + } + }) + t.Run("key missing", func(t *testing.T) { + t.Setenv(envLocalKey, "") + if got := driverLLMEnv(&config{explain: true, explainProvider: providerLocal}); got != "" { + t.Errorf("driverLLMEnv = %q, want empty when key unset", got) + } + }) +} + +func TestRsyncArgs(t *testing.T) { + tests := []struct { + name string + sshConfig string + want []string + }{ + { + name: "no ssh config", + sshConfig: "", + want: []string{"-azs", "--partial", "--progress"}, + }, + { + name: "custom ssh config", + sshConfig: "/home/me/.ssh/cluster", + want: []string{"-azs", "--partial", "--progress", "-e", "ssh -F '/home/me/.ssh/cluster'"}, + }, + { + name: "ssh config with spaces", + sshConfig: "/home/me/SSH configs/cluster", + want: []string{"-azs", "--partial", "--progress", "-e", "ssh -F '/home/me/SSH configs/cluster'"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rsyncArgs(tt.sshConfig) + if !slices.Equal(got, tt.want) { + t.Errorf("rsyncArgs(%q) = %v, want %v", tt.sshConfig, got, tt.want) + } + }) + } +} + +func TestRsyncRemoteSpecPreservesPath(t *testing.T) { + tests := []struct { + name string + driver string + path string + want string + }{ + {"Simple", "driver", "/tmp/results", "driver:/tmp/results"}, + {"Spaces", "driver", "/scratch/team data/results", "driver:/scratch/team data/results"}, + {"SingleQuote", "driver", "/scratch/team's/results", "driver:/scratch/team's/results"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := rsyncRemoteSpec(tt.driver, tt.path); got != tt.want { + t.Errorf("rsyncRemoteSpec(%q, %q) = %q, want %q", tt.driver, tt.path, got, tt.want) + } + }) + } +} + +func TestFinishedRemotely(t *testing.T) { + if !finishedRemotely(nil) { + t.Error("nil error should mean finished") + } + if !finishedRemotely(&mockExitStatus{code: 3}) { + t.Error("a non-zero remote exit should mean finished (with failed runs)") + } + if finishedRemotely(errors.New("connection reset")) { + t.Error("a transport error should mean not finished") + } +} + +func TestMaxNodeCount(t *testing.T) { + if got := maxNodeCount(sweepConfig{numNodes: []int{3, 17, 9}}); got != 17 { + t.Errorf("maxNodeCount = %d, want 17", got) + } +} + +func TestJoinInts(t *testing.T) { + if got := joinInts([]int{1, 4, 16}); got != "1,4,16" { + t.Errorf("joinInts = %q, want %q", got, "1,4,16") + } + if got := joinInts([]int{7}); got != "7" { + t.Errorf("joinInts = %q, want %q", got, "7") + } +} + +func TestGeneratedSSHConfig(t *testing.T) { + cfg := generatedSSHConfig() + for _, want := range []string{"Host *", "StrictHostKeyChecking no"} { + if !strings.Contains(cfg, want) { + t.Errorf("generated ssh config missing %q", want) + } + } +} + +// TestChooseCollectMode verifies the -collect decision: a prior compact +// collection (the marker) means the full raw archive is wanted; otherwise a +// present compact transfer is downloaded; and a missing compact transfer (the +// driven sweep aborted before exporting, e.g. a netcheck failure) falls back +// to salvaging partial output instead of failing with a raw rsync error. +func TestChooseCollectMode(t *testing.T) { + tests := []struct { + name string + marked, exists bool + want collectMode + }{ + {name: "first collect", marked: false, exists: true, want: collectCompact}, + {name: "second collect archives raw", marked: true, exists: true, want: collectFull}, + {name: "aborted before export", marked: false, exists: false, want: collectSalvage}, + {name: "marked but transfer gone", marked: true, exists: false, want: collectFull}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := chooseCollectMode(tt.marked, tt.exists); got != tt.want { + t.Errorf("chooseCollectMode(%v, %v) = %v, want %v", tt.marked, tt.exists, got, tt.want) + } + }) + } +} + +func TestDriverCompactMarkerPath(t *testing.T) { + if got := driverCompactMarkerPath("/tmp/sweep-driver-e1"); got != "/tmp/sweep-driver-e1/compact.collected" { + t.Fatalf("driverCompactMarkerPath = %q", got) + } +} + +func TestDriverCleanupCommand(t *testing.T) { + cmd := driverCleanupCommand("bb1", "/home/me/ssh.config", "/tmp/sweep-driver-e1") + for _, want := range []string{"ssh", "-F", "/home/me/ssh.config", "bb1", "rm -rf"} { + if !strings.Contains(cmd, want) { + t.Errorf("cleanup command missing %q\ngot: %s", want, cmd) + } + } + if want := "'/tmp/sweep-driver-e1'"; !strings.Contains(cmd, want) { + t.Errorf("cleanup command missing quoted work dir %q\ngot: %s", want, cmd) + } + if strings.Contains(cmd, "'ssh'") || strings.Contains(cmd, "'bb1'") { + t.Errorf("cleanup command should not quote plain tokens\ngot: %s", cmd) + } +} + +func TestBootstrapScript(t *testing.T) { + s := bootstrapScript("/tmp/wd", "'/tmp/wd/sweep' '-driven'", "", 65536) + for _, want := range []string{"setsid", "exit.code", "tail -n +1 -F console.log", "WD='/tmp/wd'", "ulimit -Sn 65536 2>/dev/null", "exec '/tmp/wd/sweep' '-driven'"} { + if !strings.Contains(s, want) { + t.Errorf("bootstrap script missing %q\ngot:\n%s", want, s) + } + } + if strings.Contains(s, "export ") { + t.Errorf("bootstrap script exports env with no envExport given:\n%s", s) + } +} + +func TestBootstrapScriptNoFdLimit(t *testing.T) { + s := bootstrapScript("/tmp/wd", "'/tmp/wd/sweep' '-driven'", "", 0) + if strings.Contains(s, "ulimit") { + t.Errorf("bootstrap script raised fd limit with fdLimit=0\ngot:\n%s", s) + } +} + +func TestBootstrapScriptEnvExport(t *testing.T) { + s := bootstrapScript("/tmp/wd", "'/tmp/wd/sweep' '-driven'", "OLLAMA_API_KEY='secret'", 65536) + if !strings.Contains(s, "export OLLAMA_API_KEY='secret'") { + t.Errorf("bootstrap script missing env export\ngot:\n%s", s) + } + // The key must reach the run via the inherited environment only — never the + // run.sh body (which is written to disk) nor the sweep argv. + if strings.Contains(s, "exec OLLAMA_API_KEY=") || strings.Contains(s, "-explain-key") { + t.Errorf("key leaked into run.sh body or argv\ngot:\n%s", s) + } +} + +// pollTestWindow and pollTestInterval keep the poll-logic tests fast and +// deterministic (real time.Sleep, but on the order of milliseconds). +const ( + pollTestWindow = 60 * time.Millisecond + pollTestInterval = 10 * time.Millisecond +) + +// pollCheck helpers build the dialed/crashed closures the tests inject. +func constCheck(v bool) func() (bool, error) { return func() (bool, error) { return v, nil } } +func errCheck() func() (bool, error) { + return func() (bool, error) { return false, errors.New("transient session error") } +} + +// TestPollDetachedStartupDialed: once the peer-dial marker appears, the wait +// ends cleanly (safe to disconnect). +func TestPollDetachedStartupDialed(t *testing.T) { + err := pollDetachedStartup(constCheck(true), constCheck(false), func() string { return "" }, "bb1", "/tmp/wd", pollTestWindow, pollTestInterval) + if err != nil { + t.Fatalf("expected a safe verdict once dialed, got error: %v", err) + } +} + +// TestPollDetachedStartupCrash: exit.code without the dial marker is a setup +// failure and must be reported with the console tail. +func TestPollDetachedStartupCrash(t *testing.T) { + err := pollDetachedStartup(constCheck(false), constCheck(true), func() string { return "boom" }, "bb1", "/tmp/wd", pollTestWindow, pollTestInterval) + if err == nil { + t.Fatal("expected an error for a crash before dialing, got nil") + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("expected error to include the console tail\ngot: %v", err) + } +} + +// TestPollDetachedStartupDialedBeatsCrash: if both the marker and exit.code +// exist (a run that dialed and then finished), the dial marker wins — it is a +// success, not a setup crash. Guards against a false crash report on a run +// that completed within the window. +func TestPollDetachedStartupDialedBeatsCrash(t *testing.T) { + err := pollDetachedStartup(constCheck(true), constCheck(true), func() string { return "done" }, "bb1", "/tmp/wd", pollTestWindow, pollTestInterval) + if err != nil { + t.Fatalf("a run that dialed then exited is not a setup crash: %v", err) + } +} + +// TestPollDetachedStartupDialsAfterWaiting: the marker appearing on a later +// poll (slow setup) still resolves to a safe verdict. +func TestPollDetachedStartupDialsAfterWaiting(t *testing.T) { + calls := 0 + dialed := func() (bool, error) { calls++; return calls >= 2, nil } + err := pollDetachedStartup(dialed, constCheck(false), func() string { return "" }, "bb1", "/tmp/wd", pollTestWindow, pollTestInterval) + if err != nil { + t.Fatalf("expected a safe verdict once the marker appears: %v", err) + } + if calls < 2 { + t.Errorf("expected the wait to poll until the marker appeared, got %d call(s)", calls) + } +} + +// TestPollDetachedStartupDetectsCrashAfterTransientError guards against the +// bug where a single poll error (a flaky SSH session, exactly what this wait +// exists to route around) made the launcher stop early: a crash discovered on +// a later poll must still be reported. +func TestPollDetachedStartupDetectsCrashAfterTransientError(t *testing.T) { + calls := 0 + crashed := func() (bool, error) { + calls++ + if calls == 1 { + return false, errors.New("transient session error") + } + return true, nil + } + err := pollDetachedStartup(constCheck(false), crashed, func() string { return "crashed" }, "bb1", "/tmp/wd", pollTestWindow, pollTestInterval) + if err == nil { + t.Fatal("expected the crash discovered after the transient error to be reported") + } +} + +// TestPollDetachedStartupAllPollsFail confirms that persistent poll failures +// across the whole window are treated as inconclusive (not a crash), since a +// broken wait says nothing about whether the detached run is fine. +func TestPollDetachedStartupAllPollsFail(t *testing.T) { + err := pollDetachedStartup(errCheck(), errCheck(), func() string { return "" }, "bb1", "/tmp/wd", pollTestWindow, pollTestInterval) + if err != nil { + t.Fatalf("persistent poll failures should be inconclusive, not a reported crash: %v", err) + } +} + +func TestEarlyExitMessage(t *testing.T) { + msg := earlyExitMessage("bb1", "/tmp/wd", "") + for _, want := range []string{"bb1", "/tmp/wd", "not a normal completion"} { + if !strings.Contains(msg, want) { + t.Errorf("early exit message missing %q\ngot: %s", want, msg) + } + } + if strings.Contains(msg, "last console output") { + t.Errorf("early exit message should not mention console output with an empty tail\ngot: %s", msg) + } + + withTail := earlyExitMessage("bb1", "/tmp/wd", "connecting to 25 host(s)...\nno valid authentication methods found for bb2") + if !strings.Contains(withTail, "no valid authentication methods found for bb2") { + t.Errorf("early exit message missing console tail\ngot: %s", withTail) + } +} + +func TestDetachBootstrapScript(t *testing.T) { + s := detachBootstrapScript("/tmp/wd", "'/tmp/wd/sweep' '-driven'", "", 65536) + for _, want := range []string{"setsid", "run.sh", "[driver] detached sweep started in $WD", "WD='/tmp/wd'", "exec '/tmp/wd/sweep' '-driven'"} { + if !strings.Contains(s, want) { + t.Errorf("detach bootstrap script missing %q\ngot:\n%s", want, s) + } + } + for _, notWant := range []string{"tail -n +1 -F console.log", "while [ ! -f exit.code ]"} { + if strings.Contains(s, notWant) { + t.Errorf("detach bootstrap script should not wait on the run, found %q\ngot:\n%s", notWant, s) + } + } + if strings.Contains(s, "export ") { + t.Errorf("detach bootstrap script exports env with no envExport given:\n%s", s) + } +} + +func TestDetachBootstrapScriptEnvExport(t *testing.T) { + s := detachBootstrapScript("/tmp/wd", "'/tmp/wd/sweep' '-driven'", "OLLAMA_API_KEY='secret'", 65536) + if !strings.Contains(s, "export OLLAMA_API_KEY='secret'") { + t.Errorf("detach bootstrap script missing env export\ngot:\n%s", s) + } + if strings.Contains(s, "exec OLLAMA_API_KEY=") || strings.Contains(s, "-explain-key") { + t.Errorf("key leaked into run.sh body or argv\ngot:\n%s", s) + } +} + +// TestFileSHA256 guards the content hash cachedUpload relies on to decide +// whether a binary needs re-uploading: identical bytes must hash identically +// regardless of file name or path, and different bytes must hash differently, +// or a stale binary could be mistaken for a current one (or vice versa). +func TestFileSHA256(t *testing.T) { + dir := t.TempDir() + pathA := filepath.Join(dir, "a") + pathB := filepath.Join(dir, "b") + pathC := filepath.Join(dir, "c") + if err := os.WriteFile(pathA, []byte("same content"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pathB, []byte("same content"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(pathC, []byte("different content"), 0o644); err != nil { + t.Fatal(err) + } + + hashA, err := fileSHA256(pathA) + if err != nil { + t.Fatalf("fileSHA256(a): %v", err) + } + hashB, err := fileSHA256(pathB) + if err != nil { + t.Fatalf("fileSHA256(b): %v", err) + } + hashC, err := fileSHA256(pathC) + if err != nil { + t.Fatalf("fileSHA256(c): %v", err) + } + + if hashA != hashB { + t.Errorf("identical content hashed differently: %q vs %q", hashA, hashB) + } + if hashA == hashC { + t.Errorf("different content hashed identically: %q", hashA) + } + if _, err := fileSHA256(filepath.Join(dir, "missing")); err == nil { + t.Error("expected an error hashing a missing file") + } +} diff --git a/benchkit/cmd/sweep/eta.go b/benchkit/cmd/sweep/eta.go new file mode 100644 index 00000000..7213b884 --- /dev/null +++ b/benchkit/cmd/sweep/eta.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "strings" + "time" +) + +// perRunOverhead is the rough per-run cost outside the measurement window +// (-time): killing lingering processes, checking that ports are free, launching +// the nodes, the AwaitReady handshake and clock sync, the post-run exit grace +// (which scales with the node count and dominates this term), and downloading +// the result files. -trim does not appear here: it only drops warmup samples +// when summarizing and never extends a run's wall-clock. This constant forms +// the upper bound of the static estimate; actual first-run and elapsed timings +// never recalibrate the displayed range. +const perRunOverhead = 15 * time.Second + +// sweepFactorBreakdown renders the multiplicative factors behind the run count, +// e.g. "n:3 × workers:3 × payload:3 × stream:2 × reps:10", so the up-front +// estimate shows how the sweep parameters produce the total. Factors that +// contribute a single value are omitted to keep the line readable; the result +// is "" when every factor is 1 (a single run). +func sweepFactorBreakdown(sc sweepConfig) string { + streamModes := max(len(sc.streamModes), 1) // empty defaults to a single mode (dual) + factors := []struct { + name string + n int + }{ + {"n", len(sc.numNodes)}, + {"workers", len(sc.workers)}, + {"payload", len(sc.payloads)}, + {"rate", len(sc.rates)}, + {"send-buffer", len(sc.sendBuffers)}, + {"recv-buffer", len(sc.recvBuffers)}, + {"bench", len(sc.benchmarks)}, + {"stream", streamModes}, + {"reps", max(sc.reps, 1)}, + } + var parts []string + for _, f := range factors { + if f.n > 1 { + parts = append(parts, fmt.Sprintf("%s:%d", f.name, f.n)) + } + } + return strings.Join(parts, " × ") +} + +func sweepForecast(now time.Time, completed, total int, duration time.Duration) (earliest, latest time.Duration, earliestFinish, latestFinish time.Time) { + left := max(total-completed, 0) + earliest = duration * time.Duration(left) + latest = (duration + perRunOverhead) * time.Duration(left) + return earliest, latest, now.Add(earliest), now.Add(latest) +} + +// sweepEstimateLine renders the up-front "estimated sweep time" line shown +// before the first run completes, from the run count (the product of the +// swept parameters) and the per-run wall-clock (-time plus perRunOverhead). +// Shared by the local run path and the driver launcher so a -detach run, +// which never streams the driven sweep's own log back to the laptop, still +// reports the estimate before the launcher disconnects. +func sweepEstimateLine(sc sweepConfig, duration time.Duration) string { + total := countRuns(sc) + if total == 0 { + return "" + } + earliest, latest, earliestFinish, latestFinish := sweepForecast(time.Now(), 0, total, duration) + breakdown := sweepFactorBreakdown(sc) + if breakdown != "" { + breakdown = " (" + breakdown + ")" + } + return fmt.Sprintf("estimated sweep time: %d run(s)%s: %s–%s; earliest–latest finish %s–%s (static estimate, %s measurement + up to %s overhead/run)", + total, breakdown, formatETA(earliest), formatETA(latest), + earliestFinish.Format("15:04 MST"), latestFinish.Format("15:04 MST"), + formatETA(duration), formatETA(perRunOverhead)) +} + +func sweepProgressLine(now time.Time, duration time.Duration, completed, total int) string { + earliest, latest, earliestFinish, latestFinish := sweepForecast(now, completed, total, duration) + return fmt.Sprintf(" %s–%s remaining, finish %s–%s (%d/%d done; static estimate)", + formatETA(earliest), formatETA(latest), + earliestFinish.Format("15:04 MST"), latestFinish.Format("15:04 MST"), + completed, total) +} + +// formatETA renders d as a compact human duration for progress output: +// "1h05m" when at least an hour, "12m" when at least a minute, and "45s" +// otherwise. A negative duration is clamped to zero. +func formatETA(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", max(int(d.Round(time.Second).Seconds()), 0)) + } + d = d.Round(time.Minute) + h := int(d / time.Hour) + m := int((d % time.Hour) / time.Minute) + if h > 0 { + return fmt.Sprintf("%dh%02dm", h, m) + } + return fmt.Sprintf("%dm", m) +} diff --git a/benchkit/cmd/sweep/eta_test.go b/benchkit/cmd/sweep/eta_test.go new file mode 100644 index 00000000..a87e31c2 --- /dev/null +++ b/benchkit/cmd/sweep/eta_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestSweepForecast(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + const duration = 10 * time.Second + tests := []struct { + name string + completed, total int + wantEarliest, wantLast time.Duration + }{ + { + name: "AllRuns", completed: 0, total: 10, + wantEarliest: 100 * time.Second, wantLast: 250 * time.Second, + }, + { + name: "RemainingRuns", completed: 2, total: 10, + wantEarliest: 80 * time.Second, wantLast: 200 * time.Second, + }, + { + name: "AllComplete", completed: 10, total: 10, + }, + { + name: "OvershootClampsToZero", completed: 11, total: 10, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + earliest, latest, earliestFinish, latestFinish := sweepForecast(now, tt.completed, tt.total, duration) + if earliest != tt.wantEarliest || latest != tt.wantLast { + t.Errorf("range = %v-%v, want %v-%v", earliest, latest, tt.wantEarliest, tt.wantLast) + } + if !earliestFinish.Equal(now.Add(tt.wantEarliest)) || !latestFinish.Equal(now.Add(tt.wantLast)) { + t.Errorf("finish range = %v-%v", earliestFinish, latestFinish) + } + }) + } +} + +func TestSweepFactorBreakdown(t *testing.T) { + tests := []struct { + name string + sc sweepConfig + want string + }{ + { + // The dedup-eval-v3 example: n:3 × workers:3 × payload:3 × stream:2 × reps:10. + name: "MultiFactor", + sc: sweepConfig{ + numNodes: []int{9, 15, 29}, + workers: []int{8, 16, 32}, + payloads: []int{1024, 4096, 16384}, + rates: []int{0}, + benchmarks: []string{"SymmetricQuorumCall"}, + streamModes: []string{"dual", "dedup"}, + reps: 10, + }, + want: "n:3 × workers:3 × payload:3 × stream:2 × reps:10", + }, + { + // Every factor is a single value: no breakdown to show. + name: "SingleRun", + sc: sweepConfig{ + numNodes: []int{9}, workers: []int{1}, payloads: []int{0}, + rates: []int{0}, benchmarks: []string{"X"}, streamModes: []string{"dual"}, reps: 1, + }, + want: "", + }, + { + // An empty streamModes defaults to a single mode, so it is omitted. + name: "EmptyStreamModesOmitted", + sc: sweepConfig{ + numNodes: []int{3, 5}, workers: []int{1}, payloads: []int{0}, + rates: []int{0}, benchmarks: []string{"X"}, streamModes: nil, reps: 1, + }, + want: "n:2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sweepFactorBreakdown(tt.sc); got != tt.want { + t.Errorf("sweepFactorBreakdown() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSweepEstimateLine(t *testing.T) { + t.Run("EmptySweepYieldsNoLine", func(t *testing.T) { + if got := sweepEstimateLine(sweepConfig{}, 20*time.Second); got != "" { + t.Errorf("sweepEstimateLine() = %q, want empty", got) + } + }) + + t.Run("ReportsRunCountAndBreakdown", func(t *testing.T) { + sc := sweepConfig{ + numNodes: []int{9, 15}, + workers: []int{8}, + payloads: []int{1024}, + rates: []int{0}, + benchmarks: []string{"SymmetricQuorumCall"}, + streamModes: []string{"dual"}, + reps: 1, + } + got := sweepEstimateLine(sc, 20*time.Second) + if !strings.HasPrefix(got, "estimated sweep time: 2 run(s) (n:2): 40s–") { + t.Errorf("sweepEstimateLine() = %q", got) + } + }) +} + +func TestFormatETA(t *testing.T) { + tests := []struct { + name string + d time.Duration + want string + }{ + {name: "Seconds", d: 45 * time.Second, want: "45s"}, + {name: "SubMinuteRoundsToSeconds", d: 59500 * time.Millisecond, want: "60s"}, + {name: "Minutes", d: 12 * time.Minute, want: "12m"}, + {name: "MinutesRounded", d: 12*time.Minute + 20*time.Second, want: "12m"}, + {name: "Hours", d: time.Hour + 5*time.Minute, want: "1h05m"}, + {name: "HoursZeroPadMinutes", d: 2 * time.Hour, want: "2h00m"}, + {name: "Negative", d: -5 * time.Second, want: "0s"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatETA(tt.d); got != tt.want { + t.Errorf("formatETA(%v) = %q, want %q", tt.d, got, tt.want) + } + }) + } +} diff --git a/benchkit/cmd/sweep/explain.go b/benchkit/cmd/sweep/explain.go new file mode 100644 index 00000000..2640ae87 --- /dev/null +++ b/benchkit/cmd/sweep/explain.go @@ -0,0 +1,307 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strings" + "time" +) + +// defaultExplainMaxLog caps the node log included in the triage prompt. The log +// is sent as a head+tail window so the startup phase and the final errors both +// survive; the default keeps a typical prompt within a local model's context. +const defaultExplainMaxLog = 64 << 10 // 64 KiB + +// explainTimeout bounds a single model request. +const explainTimeout = 2 * time.Minute + +// explainCheckTimeout bounds the connectivity check, which sends a trivial +// prompt and so should answer far faster than a real diagnosis. +const explainCheckTimeout = 30 * time.Second + +// explainCheckSystem and explainCheckUser form a minimal prompt that any working +// model answers in a few tokens. The check cares only that a non-empty reply +// comes back, not what it says, so the prompt is kept tiny to stay fast. +const ( + explainCheckSystem = "You are a connectivity check." + explainCheckUser = "Reply with the single word OK." +) + +// pingProvider sends the minimal check prompt and returns the trimmed reply, +// treating a reachable model that returns an empty reply as a failure. It backs +// both -explain-check and the pre-sweep preflight. +func pingProvider(ctx context.Context, p llmProvider) (string, error) { + reply, err := p.Diagnose(ctx, explainCheckSystem, explainCheckUser) + if err != nil { + return "", err + } + reply = strings.TrimSpace(reply) + if reply == "" { + return "", fmt.Errorf("model returned an empty reply") + } + return reply, nil +} + +// runExplainCheck builds the configured provider and verifies it answers a +// trivial prompt, printing the model, round-trip latency, and reply. It backs +// the -explain-check flag, so any misconfiguration (unknown provider, missing +// key, unreachable endpoint, empty reply) surfaces on demand without waiting for +// a failed run to triage. +func runExplainCheck(cfg *config) error { + provider, err := newProvider(cfg) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), explainCheckTimeout) + defer cancel() + start := time.Now() + reply, err := pingProvider(ctx, provider) + if err != nil { + return fmt.Errorf("%s/%s: %w", cfg.explainProvider, cfg.explainModel, err) + } + log.Printf("explain check OK: %s/%s replied in %s: %q", + cfg.explainProvider, cfg.explainModel, time.Since(start).Round(time.Millisecond), reply) + return nil +} + +// salientKeywords flag log lines worth surfacing to the model even when they +// fall in the elided middle of a trimmed log. The match is case-insensitive +// substring; over-inclusion (an occasional benign line) is preferable to losing +// the one line that explains the failure. "not ready", "stall", and "127.0.1.1" +// are the fingerprints of the Type A AwaitReady failures; "refused", "timeout", +// and "deadline" mark the Type B connection errors. +var salientKeywords = []string{ + "error", "warn", "fail", "panic", "fatal", + "refused", "timeout", "timed out", "deadline", "cancel", "incomplete", "unavailable", + "not ready", "stall", "unreachable", "127.0.1.1", +} + +// explainSystemPrompt primes the model with the benchkit failure taxonomy and +// one worked example, then states the required output. It mirrors the reasoning +// in doc/benchkit-troubleshooting.html, distilled to plain text because the doc +// is outside the sweep module and not shipped with the binary; keep the two in +// sync when the taxonomy changes. +const explainSystemPrompt = `You are a distributed-systems benchmark engineer triaging a failed run of the +gorums "benchkit" toolkit. A sweep launches N peer nodes over SSH that form a +full gRPC mesh, run a timed quorum-call benchmark, write per-node result files, +then exit. You are given the run's artifacts and must diagnose the failure. + +Failure taxonomy: + + Type A - setup failure (AwaitReady / inbound unreachability). A node cannot + receive inbound peer connections, so it stalls in AwaitReady waiting for the + full mesh. After a 20s stall timeout every node cancels and reports "remote + peers not ready". No result files are produced; the process exits with status + 1. failure_phase is usually "setup". A common root cause: a host binds its + listener to a loopback address (127.0.1.1 via /etc/hosts) instead of its real + interface, so peers cannot reach it; the effect is episodic and host-specific. + + Type B - measurement failure (linger too short for completion skew). All + nodes pass AwaitReady, but during measurement some nodes run slower than + others. Fast nodes finish, write results, linger briefly, then close their + listeners and exit. A slow node issuing a later quorum call hits "connection + refused" or an incomplete-call error. Partial result files are produced (the + fast nodes succeeded); failure_phase is usually "measurement". + +Worked example (Type A): At N=25, all nodes logged "remote peers not ready: +... inbound peers not ready (connected 24/25, missing node 7 ...)". One host +(bb16, node 7) reported connected 2/25 - nearly isolated. Because AwaitReady +needs every node to see all peers, that one host stalled the whole cluster +until the 20s timer fired. Root cause: that host bound its listener to +127.0.1.1:9000 while every other host bound its real address. Fix: bind the +wildcard address. The suspect host is the one with the lowest connected count +and/or the one named as "missing" by its peers. + +Diagnose this run in at most ~8 lines of plain text, structured as: + Failure phase: + Suspect host: + Probable cause: + Next step: +Ground every claim in the artifacts. If the evidence is insufficient, say so.` + +// failedRun identifies a failed run by its base name, from which the manifest +// and per-run artifact paths are derived. +type failedRun struct { + base string +} + +// triageFailedRuns diagnoses the failed runs in cfg.outDir with the configured +// LLM, printing each verdict and recording it in the run's manifest. It runs on +// whichever host executed the sweep: on the driver for a driven sweep, or on +// the laptop for a local one. It is best-effort — any error is logged and never +// aborts the sweep or the export — because a missing API key or an unreachable +// model must not lose results. +func triageFailedRuns(cfg *config) { + provider, err := newProvider(cfg) + if err != nil { + log.Printf("warning: explain: %v", err) + return + } + runs, err := discoverFailedRuns(cfg.outDir) + if err != nil { + log.Printf("warning: explain: %v", err) + return + } + if len(runs) == 0 { + return + } + log.Printf("triaging %d failed run(s) with %s/%s...", len(runs), cfg.explainProvider, cfg.explainModel) + for _, r := range runs { + if err := explainRun(cfg, provider, cfg.outDir, r); err != nil { + log.Printf(" warning: explain %s: %v", r.base, err) + } + } +} + +// explainRun triages a single failed run: gather artifacts, query the model, and +// print and persist the verdict. +func explainRun(cfg *config, provider llmProvider, dir string, r failedRun) error { + prompt, err := gatherArtifacts(dir, r.base, cfg.explainMaxLog) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), explainTimeout) + defer cancel() + verdict, err := provider.Diagnose(ctx, explainSystemPrompt, prompt) + if err != nil { + return err + } + fmt.Printf("\n===== diagnosis: %s =====\n%s\n", r.base, verdict) + if err := updateManifestDiagnosis(dir, r.base, verdict); err != nil { + return fmt.Errorf("recording diagnosis: %w", err) + } + return nil +} + +// discoverFailedRuns returns the failed runs in dir, identified by their +// manifests (files ending in manifestSuffix with status "failed"). The base name +// is the manifest filename with the suffix stripped. +func discoverFailedRuns(dir string) ([]failedRun, error) { + paths, err := filepath.Glob(filepath.Join(dir, "*"+manifestSuffix)) + if err != nil { + return nil, err + } + var runs []failedRun + for _, path := range paths { + if manifestStatus(path) != runStatusFailed { + continue + } + base := strings.TrimSuffix(filepath.Base(path), manifestSuffix) + runs = append(runs, failedRun{base: base}) + } + return runs, nil +} + +// gatherArtifacts assembles the labeled artifact bundle for one failed run: the +// full manifest, the failure snapshot if present, the trimmed node log, and any +// matching summary rows. Sections that are absent are skipped silently. +func gatherArtifacts(dir, base string, maxLog int) (string, error) { + var b strings.Builder + fmt.Fprintf(&b, "Run base: %s\n", base) + + // The manifest is mandatory: it carries the failure phase, collected/missing + // file counts, and the node map. + manifest, err := os.ReadFile(manifestPath(dir, base)) + if err != nil { + return "", fmt.Errorf("reading manifest: %w", err) + } + appendSection(&b, "manifest.json", string(manifest)) + + if snap, err := os.ReadFile(snapshotPath(dir, base)); err == nil { + appendSection(&b, "host snapshot (logs/"+base+"_snapshot.txt)", string(snap)) + } + if logData, err := os.ReadFile(runLogPath(dir, base)); err == nil { + // Grep the whole log first so error/warning lines survive even when they + // fall in the elided middle of the head+tail window below. + if notable := salientLog(logData, maxLog); notable != "" { + appendSection(&b, "notable log lines (errors/warnings across the full log)", notable) + } + appendSection(&b, "node log (logs/"+base+".log, head+tail)", trimLog(logData, maxLog)) + } + if rows := summaryRows(dir, base); rows != "" { + appendSection(&b, "summary (plotdata.binpb)", rows) + } + return b.String(), nil +} + +// appendSection writes a labeled, fenced artifact section to b. +func appendSection(b *strings.Builder, title, body string) { + fmt.Fprintf(b, "\n--- %s ---\n%s\n", title, strings.TrimRight(body, "\n")) +} + +// trimLog returns log data unchanged when it fits within maxLog bytes; otherwise +// it keeps the first and last halves of the budget with an elision marker +// between them, so both the startup phase and the final errors survive. +func trimLog(data []byte, maxLog int) string { + if maxLog <= 0 || len(data) <= maxLog { + return string(data) + } + half := maxLog / 2 + head := data[:half] + tail := data[len(data)-half:] + elided := len(data) - 2*half + return fmt.Sprintf("%s\n... [%d bytes elided] ...\n%s", head, elided, tail) +} + +// salientLog returns the log lines matching salientKeywords, in order, capped at +// maxBytes (with a marker noting how many were omitted). It scans the full log, +// so a critical line in the trimmed-away middle still reaches the model. It +// returns "" when nothing matches. +func salientLog(data []byte, maxBytes int) string { + var b strings.Builder + var matched, kept int + for line := range strings.SplitSeq(string(data), "\n") { + if !isSalient(line) { + continue + } + matched++ + if maxBytes > 0 && b.Len()+len(line)+1 > maxBytes { + continue + } + b.WriteString(line) + b.WriteByte('\n') + kept++ + } + if matched == 0 { + return "" + } + if kept < matched { + fmt.Fprintf(&b, "... [%d more matching line(s) omitted] ...", matched-kept) + } + return strings.TrimRight(b.String(), "\n") +} + +// isSalient reports whether a log line contains any salientKeyword. +func isSalient(line string) bool { + lower := strings.ToLower(line) + return slices.ContainsFunc(salientKeywords, func(kw string) bool { + return strings.Contains(lower, kw) + }) +} + +// summaryRows returns the plotdata.binpb rows for one run's base as CSV text +// (header plus matching rows), for the LLM triage prompt. It reads the +// compact plotdata.binpb directly rather than plotdata/runs.csv: the sweep +// pipeline writes only the binpb by default, so reading the CSV found this +// section empty on every fresh output directory (runs.csv exists only after +// a manual -export-csv). +func summaryRows(dir, base string) string { + pd, err := readPlotData(dir) + if err != nil { + return "" + } + runs, _ := plotRecordsFromMessage(pd) + matched := slices.DeleteFunc(runs, func(r plotRunRecord) bool { return r.base != base }) + if len(matched) == 0 { + return "" + } + var b strings.Builder + if err := writeCSVTo(&b, plotRunsCSVHeader(), matched, plotRunCSVFields); err != nil { + return "" + } + return strings.TrimRight(b.String(), "\n") +} diff --git a/benchkit/cmd/sweep/explain_test.go b/benchkit/cmd/sweep/explain_test.go new file mode 100644 index 00000000..aa21f9cc --- /dev/null +++ b/benchkit/cmd/sweep/explain_test.go @@ -0,0 +1,518 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/relab/gorums/benchkit" +) + +// writeFailedManifest writes a started manifest for base and marks it failed in +// the given phase, returning the node assignments used. +func writeFailedManifest(t *testing.T, dir, base, phase string) []nodeAssignment { + t.Helper() + cfg := &config{sweepLabel: "nscale", duration: 10 * time.Second} + p := runSpec{ + Dimensions: benchkit.Dimensions{Nodes: 2, Workers: 1, Benchmark: "Symmetric"}, + Rep: 1, + } + nodes := []nodeAssignment{ + {host: "bb1", peerHost: "152.94.162.21", port: 9000}, + {host: "bb2", peerHost: "152.94.162.11", port: 9000}, + } + writeManifest(dir, base, p, nodes, cfg, "abc123", "/tmp/bench") + if err := updateManifestOutcome(dir, base, runOutcome{ + status: runStatusFailed, + failurePhase: phase, + }); err != nil { + t.Fatalf("update outcome: %v", err) + } + return nodes +} + +// TestUpdateManifestDiagnosis verifies that the diagnosis is recorded without +// disturbing any other manifest field, and that a second call overwrites it. +func TestUpdateManifestDiagnosis(t *testing.T) { + dir := t.TempDir() + const base = "nscale_Symmetric_N2_W1_r1" + writeFailedManifest(t, dir, base, failurePhaseSetup) + + if err := updateManifestDiagnosis(dir, base, "first verdict"); err != nil { + t.Fatalf("updateManifestDiagnosis: %v", err) + } + m := readManifest(t, dir, base) + if m.Diagnosis != "first verdict" { + t.Errorf("diagnosis = %q, want %q", m.Diagnosis, "first verdict") + } + // Other fields must survive the read-modify-write. + if m.Status != runStatusFailed || m.FailurePhase != failurePhaseSetup || + m.Benchmark != "Symmetric" || m.Nodes != 2 { + t.Errorf("write-back disturbed other fields: %+v", m) + } + + if err := updateManifestDiagnosis(dir, base, "second verdict"); err != nil { + t.Fatalf("updateManifestDiagnosis (overwrite): %v", err) + } + if m := readManifest(t, dir, base); m.Diagnosis != "second verdict" { + t.Errorf("diagnosis after overwrite = %q, want %q", m.Diagnosis, "second verdict") + } +} + +// TestTrimLog checks that a log within the cap is returned verbatim and an +// over-cap log is reduced to a head+tail window with an elision marker. +func TestTrimLog(t *testing.T) { + small := []byte("line1\nline2\n") + if got := trimLog(small, 1024); got != string(small) { + t.Errorf("under-cap log altered: %q", got) + } + + big := []byte(strings.Repeat("A", 400) + strings.Repeat("B", 400)) + const cap = 200 + got := trimLog(big, cap) + if !strings.HasPrefix(got, strings.Repeat("A", 100)) { + t.Errorf("trimmed log missing head: %q", got[:min(40, len(got))]) + } + if !strings.HasSuffix(got, strings.Repeat("B", 100)) { + t.Errorf("trimmed log missing tail") + } + if !strings.Contains(got, "bytes elided") { + t.Errorf("trimmed log missing elision marker: %q", got) + } + // Head + tail keep cap bytes; the marker adds a bounded, small overhead. + if len(got) > cap+64 { + t.Errorf("trimmed log %d bytes exceeds cap %d plus marker", len(got), cap) + } +} + +// TestDiscoverFailedRuns verifies that only failed runs are selected and that +// the base name is recovered from the manifest filename. +func TestDiscoverFailedRuns(t *testing.T) { + dir := t.TempDir() + writeFailedManifest(t, dir, "run_Symmetric_N2_W1_r1", failurePhaseSetup) + writeFailedManifest(t, dir, "run_Symmetric_N2_W1_r2", failurePhaseMeasurement) + // A succeeded run must be ignored. + cfg := &config{sweepLabel: "run", duration: time.Second} + okNodes := []nodeAssignment{{host: "bb1", peerHost: "10.0.0.1", port: 9000}} + writeManifest(dir, "run_Symmetric_N1_W1_r1", runSpec{ + Dimensions: benchkit.Dimensions{Nodes: 1, Workers: 1, Benchmark: "Symmetric"}, + Rep: 1, + }, okNodes, cfg, "", "") + if err := updateManifestOutcome(dir, "run_Symmetric_N1_W1_r1", runOutcome{status: runStatusSucceeded}); err != nil { + t.Fatalf("update outcome: %v", err) + } + + runs, err := discoverFailedRuns(dir) + if err != nil { + t.Fatalf("discoverFailedRuns: %v", err) + } + got := make([]string, len(runs)) + for i, r := range runs { + got[i] = r.base + } + slices.Sort(got) + want := []string{"run_Symmetric_N2_W1_r1", "run_Symmetric_N2_W1_r2"} + if !slices.Equal(got, want) { + t.Errorf("failed runs = %v, want %v", got, want) + } +} + +// TestGatherArtifacts checks that the bundle includes the manifest, the host +// snapshot, and a trimmed node log, each under a labeled section. +func TestGatherArtifacts(t *testing.T) { + dir := t.TempDir() + const base = "run_Symmetric_N2_W1_r1" + writeFailedManifest(t, dir, base, failurePhaseSetup) + + logsDir := filepath.Join(dir, logSubdir) + if err := os.MkdirAll(logsDir, 0o755); err != nil { + t.Fatalf("mkdir logs: %v", err) + } + if err := os.WriteFile(filepath.Join(logsDir, base+"_snapshot.txt"), []byte("===== bb1 =====\nload 0.5\n"), 0o644); err != nil { + t.Fatalf("write snapshot: %v", err) + } + // A benign head and tail with a single critical line buried in the middle, + // large enough that the head+tail window elides the middle. The grep must + // still surface the buried line. + head := strings.Repeat("[bb1:9000] tick\n", 200) + buried := "[bb16:9000] remote peers not ready: connection refused\n" + tail := strings.Repeat("[bb2:9000] tick\n", 200) + if err := os.WriteFile(filepath.Join(logsDir, base+".log"), []byte(head+buried+tail), 0o644); err != nil { + t.Fatalf("write log: %v", err) + } + + bundle, err := gatherArtifacts(dir, base, 1000) + if err != nil { + t.Fatalf("gatherArtifacts: %v", err) + } + for _, want := range []string{"manifest.json", "host snapshot", "node log", "===== bb1 =====", "bytes elided", "notable log lines", "connection refused"} { + if !strings.Contains(bundle, want) { + t.Errorf("bundle missing %q", want) + } + } + if strings.Contains(bundle, "plotdata/runs.csv") { + t.Errorf("bundle should omit absent summary section") + } +} + +// TestSummaryRowsReadsCompactPlotData verifies that summaryRows reads the +// compact plotdata.binpb the sweep pipeline actually writes, not +// plotdata/runs.csv (which exists only after a manual -export-csv and was +// previously always empty on a fresh output directory), and filters to the +// requested run's base. +func TestSummaryRowsReadsCompactPlotData(t *testing.T) { + dir := t.TempDir() + const base1, base2 = "e1_Q_N1_W1_P0", "e1_Q_N2_W1_P0" + n := nodeAssignment{host: "bb1", port: 9000} + for _, base := range []string{base1, base2} { + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 10, + Latencies: []int64{1000, 2000}, + }.Build()) + } + if err := writeCompactPlotData(dir); err != nil { + t.Fatalf("writeCompactPlotData: %v", err) + } + + rows := summaryRows(dir, base1) + if rows == "" { + t.Fatal("summaryRows returned empty for a run present in plotdata.binpb") + } + lines := strings.Split(rows, "\n") + if len(lines) != 2 { + t.Fatalf("rows = %d lines, want 2 (header + one matching run)", len(lines)) + } + if !strings.HasPrefix(lines[0], "base,label,status,rep,") { + t.Errorf("header = %q, want it to start with the CSV column names", lines[0]) + } + if !strings.HasPrefix(lines[1], base1+",") { + t.Errorf("data row = %q, want it to start with %q", lines[1], base1+",") + } + if strings.Contains(rows, base2) { + t.Errorf("rows include the other run's base %q, want only %q", base2, base1) + } + + if got := summaryRows(dir, "no-such-run"); got != "" { + t.Errorf("summaryRows(unmatched base) = %q, want empty", got) + } +} + +// TestSalientLog verifies that error/warning lines are extracted from anywhere +// in the log, that a no-match log yields "", and that the byte cap is honored +// with an omission marker. +func TestSalientLog(t *testing.T) { + log := []byte("starting up\n" + + "[bb1:9000] all good\n" + + "[bb16:9000] inbound peers not ready: no new peer for 20s\n" + + "[bb2:9000] WARNING: offered rate not sustained\n" + + "shutting down\n") + got := salientLog(log, 0) + if !strings.Contains(got, "not ready") || !strings.Contains(got, "WARNING") { + t.Errorf("salient lines missing expected matches: %q", got) + } + if strings.Contains(got, "all good") || strings.Contains(got, "starting up") { + t.Errorf("salient lines include benign lines: %q", got) + } + + if got := salientLog([]byte("line a\nline b\n"), 0); got != "" { + t.Errorf("no-match log = %q, want empty", got) + } + + many := []byte(strings.Repeat("[bb1:9000] connection refused\n", 100)) + capped := salientLog(many, 200) + if len(capped) > 200+64 { + t.Errorf("capped salient log = %d bytes, want <= cap plus marker", len(capped)) + } + if !strings.Contains(capped, "more matching line(s) omitted") { + t.Errorf("capped salient log missing omission marker: %q", capped) + } +} + +// TestOpenAIProviderDiagnose verifies the request shape and reply parsing for +// the OpenAI-compatible client used by the local and openai providers. +func TestOpenAIProviderDiagnose(t *testing.T) { + var gotAuth, gotPath string + var gotBody struct { + Model string `json:"model"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + data, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(data, &gotBody); err != nil { + t.Errorf("unmarshal request: %v", err) + } + io.WriteString(w, `{"choices":[{"message":{"content":" verdict text "}}]}`) + })) + defer srv.Close() + + p := &openAIProvider{chatClient{baseURL: srv.URL, apiKey: "k-123", model: "llama3.3", client: srv.Client()}} + got, err := p.Diagnose(context.Background(), "sys", "usr") + if err != nil { + t.Fatalf("Diagnose: %v", err) + } + if got != "verdict text" { + t.Errorf("verdict = %q, want trimmed %q", got, "verdict text") + } + if gotPath != "/v1/chat/completions" { + t.Errorf("path = %q", gotPath) + } + if gotAuth != "Bearer k-123" { + t.Errorf("auth = %q", gotAuth) + } + if gotBody.Model != "llama3.3" || len(gotBody.Messages) != 2 || + gotBody.Messages[0].Role != "system" || gotBody.Messages[0].Content != "sys" || + gotBody.Messages[1].Role != "user" || gotBody.Messages[1].Content != "usr" { + t.Errorf("request body = %+v", gotBody) + } +} + +// TestOllamaProviderDiagnose verifies the request shape and reply parsing for +// the native Ollama /api/chat client used by the local provider, including the +// guard that turns an empty message into an error. +func TestOllamaProviderDiagnose(t *testing.T) { + t.Run("ok", func(t *testing.T) { + var gotAuth, gotPath string + var gotBody struct { + Model string `json:"model"` + Stream bool `json:"stream"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + data, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(data, &gotBody); err != nil { + t.Errorf("unmarshal request: %v", err) + } + // Native replies carry a single message object, not a choices array, + // and may include a thinking field the client must ignore. + io.WriteString(w, `{"message":{"role":"assistant","content":" verdict text ","thinking":"reasoning"},"done":true}`) + })) + defer srv.Close() + + p := &ollamaProvider{chatClient{baseURL: srv.URL, apiKey: "k-123", model: "gemma4:31b", client: srv.Client()}} + got, err := p.Diagnose(context.Background(), "sys", "usr") + if err != nil { + t.Fatalf("Diagnose: %v", err) + } + if got != "verdict text" { + t.Errorf("verdict = %q, want trimmed %q", got, "verdict text") + } + if gotPath != "/api/chat" { + t.Errorf("path = %q, want /api/chat", gotPath) + } + if gotAuth != "Bearer k-123" { + t.Errorf("auth = %q", gotAuth) + } + if gotBody.Model != "gemma4:31b" || gotBody.Stream != false || len(gotBody.Messages) != 2 || + gotBody.Messages[0].Role != "system" || gotBody.Messages[0].Content != "sys" || + gotBody.Messages[1].Role != "user" || gotBody.Messages[1].Content != "usr" { + t.Errorf("request body = %+v", gotBody) + } + }) + + t.Run("empty message", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"message":{"role":"assistant","content":""},"done":true}`) + })) + defer srv.Close() + p := &ollamaProvider{chatClient{baseURL: srv.URL, apiKey: "k", model: "m", client: srv.Client()}} + if _, err := p.Diagnose(context.Background(), "s", "u"); err == nil { + t.Error("want error for empty message") + } + }) +} + +// TestAnthropicProviderDiagnose verifies the request shape and reply parsing for +// the Anthropic Messages API client used by the claude provider. +func TestAnthropicProviderDiagnose(t *testing.T) { + var gotKey, gotVersion, gotPath, gotSystem string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotKey = r.Header.Get("X-Api-Key") + gotVersion = r.Header.Get("Anthropic-Version") + gotPath = r.URL.Path + var body struct { + System string `json:"system"` + } + data, _ := io.ReadAll(r.Body) + json.Unmarshal(data, &body) + gotSystem = body.System + io.WriteString(w, `{"content":[{"text":"claude verdict"}]}`) + })) + defer srv.Close() + + p := &anthropicProvider{chatClient{baseURL: srv.URL, apiKey: "sk-ant", model: "claude-opus-4-8", client: srv.Client()}} + got, err := p.Diagnose(context.Background(), "sys", "usr") + if err != nil { + t.Fatalf("Diagnose: %v", err) + } + if got != "claude verdict" { + t.Errorf("verdict = %q", got) + } + if gotPath != "/v1/messages" { + t.Errorf("path = %q", gotPath) + } + if gotKey != "sk-ant" || gotVersion != anthropicVersion { + t.Errorf("headers: key=%q version=%q", gotKey, gotVersion) + } + if gotSystem != "sys" { + t.Errorf("system = %q, want %q", gotSystem, "sys") + } +} + +// TestProviderErrorStatus verifies that a non-2xx reply surfaces as an error +// carrying the response body. +func TestProviderErrorStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + io.WriteString(w, `{"error":"bad key"}`) + })) + defer srv.Close() + + p := &openAIProvider{chatClient{baseURL: srv.URL, apiKey: "k", model: "m", client: srv.Client()}} + _, err := p.Diagnose(context.Background(), "s", "u") + if err == nil || !strings.Contains(err.Error(), "bad key") { + t.Errorf("error = %v, want one mentioning the response body", err) + } +} + +// TestProviderEmptyBody reproduces the failure that surfaced only as "unexpected +// end of JSON input": a 2xx reply with an empty body. The error must now name the +// status and the zero-length body so the cause is visible without re-running. +func TestProviderEmptyBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 200 OK with no body written. + })) + defer srv.Close() + + p := &openAIProvider{chatClient{baseURL: srv.URL, apiKey: "k", model: "m", client: srv.Client()}} + _, err := p.Diagnose(context.Background(), "s", "u") + if err == nil { + t.Fatal("want error for empty 2xx body") + } + for _, want := range []string{"decoding response", "200 OK", "0-byte"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q missing %q", err.Error(), want) + } + } +} + +// TestProviderMalformedBody verifies that a 2xx reply with non-JSON content +// surfaces as a decode error that includes a snippet of the offending body. +func TestProviderMalformedBody(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "gateway timeout") + })) + defer srv.Close() + + p := &openAIProvider{chatClient{baseURL: srv.URL, apiKey: "k", model: "m", client: srv.Client()}} + _, err := p.Diagnose(context.Background(), "s", "u") + if err == nil { + t.Fatal("want error for malformed 2xx body") + } + if !strings.Contains(err.Error(), "decoding response") || !strings.Contains(err.Error(), "gateway timeout") { + t.Errorf("error %q missing decode context or body snippet", err.Error()) + } +} + +// TestPingProvider verifies the connectivity check: a non-empty reply passes and +// is returned trimmed, while a reachable model that returns an empty reply fails. +func TestPingProvider(t *testing.T) { + t.Run("ok", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"choices":[{"message":{"content":" OK "}}]}`) + })) + defer srv.Close() + p := &openAIProvider{chatClient{baseURL: srv.URL, apiKey: "k", model: "m", client: srv.Client()}} + got, err := pingProvider(context.Background(), p) + if err != nil { + t.Fatalf("pingProvider: %v", err) + } + if got != "OK" { + t.Errorf("reply = %q, want trimmed %q", got, "OK") + } + }) + + t.Run("empty reply", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"choices":[{"message":{"content":" "}}]}`) + })) + defer srv.Close() + p := &openAIProvider{chatClient{baseURL: srv.URL, apiKey: "k", model: "m", client: srv.Client()}} + if _, err := pingProvider(context.Background(), p); err == nil { + t.Error("want error for empty reply") + } + }) +} + +// TestNewProvider checks provider selection, endpoint defaults, and the +// required-model and required-key guards. +func TestNewProvider(t *testing.T) { + t.Run("missing model", func(t *testing.T) { + if _, err := newProvider(&config{explainProvider: providerLocal}); err == nil { + t.Error("want error when -explain-model is empty") + } + }) + + t.Run("missing key", func(t *testing.T) { + t.Setenv(envLocalKey, "") + if _, err := newProvider(&config{explainProvider: providerLocal, explainModel: "llama3.3"}); err == nil { + t.Error("want error when key env var is unset") + } + }) + + t.Run("unknown provider", func(t *testing.T) { + t.Setenv(envLocalKey, "k") + if _, err := newProvider(&config{explainProvider: "bogus", explainModel: "m"}); err == nil { + t.Error("want error for unknown provider") + } + }) + + t.Run("local selects ollama", func(t *testing.T) { + t.Setenv(envLocalKey, "k") + p, err := newProvider(&config{explainProvider: providerLocal, explainModel: "llama3.3"}) + if err != nil { + t.Fatalf("newProvider: %v", err) + } + op, ok := p.(*ollamaProvider) + if !ok { + t.Fatalf("provider type = %T, want *ollamaProvider", p) + } + if op.baseURL != defaultLocalEndpoint { + t.Errorf("baseURL = %q, want %q", op.baseURL, defaultLocalEndpoint) + } + }) + + t.Run("claude selects anthropic", func(t *testing.T) { + t.Setenv(envClaudeKey, "sk") + p, err := newProvider(&config{explainProvider: providerClaude, explainModel: "claude-opus-4-8"}) + if err != nil { + t.Fatalf("newProvider: %v", err) + } + ap, ok := p.(*anthropicProvider) + if !ok { + t.Fatalf("provider type = %T, want *anthropicProvider", p) + } + if ap.baseURL != defaultClaudeEndpoint { + t.Errorf("baseURL = %q, want %q", ap.baseURL, defaultClaudeEndpoint) + } + }) +} diff --git a/benchkit/cmd/sweep/health.go b/benchkit/cmd/sweep/health.go new file mode 100644 index 00000000..942880f8 --- /dev/null +++ b/benchkit/cmd/sweep/health.go @@ -0,0 +1,197 @@ +package main + +import ( + "cmp" + "context" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/relab/iago" +) + +// Post-incident health probes capture transient host evidence immediately after +// failed or degraded runs and store it beside the run artifacts. + +const ( + // healthProbeTimeout bounds the whole probe (diag command plus the + // ping-ring bonus) so a stuck or unreachable host cannot delay the sweep + // beyond a few seconds; the probe runs synchronously between one run + // finishing and the next starting. + healthProbeTimeout = 8 * time.Second + // healthProbePings and healthProbePingDeadlineS size the ping-ring bonus + // check. They are tighter than netcheck's preflight probe (netcheck.go), + // which affords a full 8s deadline before the sweep even starts; this + // probe runs mid-sweep and must stay cheap. + healthProbePings = 5 + healthProbePingDeadlineS = "2" + // healthProbePeers caps how many other hosts are ping-checked from each + // implicated host. + healthProbePeers = 2 +) + +// healthProbeHosts returns the SSH-alias hosts implicated by a run's outcome: +// the hosts of nodes whose result file is missing for a failed run, or the +// flagged hosts for a degraded run. Returns nil for a successful run or when +// no host is implicated (e.g. a failure recorded before any node was +// assigned a host). +func healthProbeHosts(o runOutcome, base string, nodes []nodeAssignment) []string { + switch o.status { + case runStatusFailed: + return missingFileHosts(o.missingFiles, base, nodes) + case runStatusDegraded: + return degradedNodeHosts(o.degraded) + default: + return nil + } +} + +// missingFileHosts returns the deduplicated host aliases of nodes whose +// expected result file appears in missing, in node order. +func missingFileHosts(missing []string, base string, nodes []nodeAssignment) []string { + if len(missing) == 0 { + return nil + } + missingSet := make(map[string]bool, len(missing)) + for _, m := range missing { + missingSet[m] = true + } + var hosts []string + seen := make(map[string]bool, len(nodes)) + for _, n := range nodes { + if !missingSet[resultFilename(base, n, resultExt)] || seen[n.host] { + continue + } + seen[n.host] = true + hosts = append(hosts, n.host) + } + return hosts +} + +// degradedNodeHosts returns the deduplicated host aliases of the flagged +// nodes, in the given (worst-first) order. degradedNode.Host is a +// "host:port" label; only the bare alias is dialable via SSH. +func degradedNodeHosts(degraded []degradedNode) []string { + if len(degraded) == 0 { + return nil + } + var hosts []string + seen := make(map[string]bool, len(degraded)) + for _, d := range degraded { + host := hostFromAddr(d.Host) + if seen[host] { + continue + } + seen[host] = true + hosts = append(hosts, host) + } + return hosts +} + +// healthyPingTargets returns up to n peer addresses of hosts not in +// implicated, for the health probe's ping-ring bonus check. Order follows +// allHosts, so the choice is deterministic. +func healthyPingTargets(allHosts []hostAssignment, implicated map[string]bool, n int) []string { + var targets []string + for _, h := range allHosts { + if implicated[h.alias] { + continue + } + targets = append(targets, cmp.Or(h.peerHost, h.alias)) + if len(targets) == n { + break + } + } + return targets +} + +// healthProbeCommand returns the ping command for the health probe's +// ping-ring bonus check: fewer pings and a shorter deadline than netcheck's +// preflight probe, since this runs mid-sweep and must stay cheap. +func healthProbeCommand(target string) string { + return fmt.Sprintf("ping -c %d -i 0.2 -w %s -q %s", + healthProbePings, healthProbePingDeadlineS, iago.Quote(target)) +} + +// healthProbePath returns the post-incident health-probe path for base under +// outdir. +func healthProbePath(outdir, base string) string { + return filepath.Join(outdir, logSubdir, base+"_health.txt") +} + +// runHealthProbe gathers a lightweight diagnostic snapshot (load, busy +// ports, and TCP counters via diagCommand, plus a ping-ring check against a +// couple of healthy peers) from each implicated host and writes it to +// /logs/_health.txt. remoteRoot is the storage namespace +// diagCommand inspects for free space and staleness (cfg.remoteDir, not a +// hardcoded path), so the probe reports on the directory the sweep actually +// uses. It reuses the diag.go probe machinery over g's already-connected SSH +// sessions rather than opening new connections. It returns the written path, +// or "" when there was nothing to probe or the probe could not even be +// started; either way it is best-effort — a probe failure is logged and +// swallowed, never propagated, so it cannot fail or delay the sweep beyond +// healthProbeTimeout. +func runHealthProbe(g iago.Group, allHosts []hostAssignment, prog remoteProgram, basePort int, hosts []string, outdir, base, remoteRoot string) string { + if len(hosts) == 0 { + return "" + } + implicated := make(map[string]bool, len(hosts)) + for _, h := range hosts { + implicated[h] = true + } + sub := g + sub.Hosts = nil + for _, h := range g.Hosts { + if implicated[h.Name()] { + sub.Hosts = append(sub.Hosts, h) + } + } + if len(sub.Hosts) == 0 { + return "" + } + + dir := filepath.Join(outdir, logSubdir) + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Printf(" warning: health probe: %v", err) + return "" + } + path := healthProbePath(outdir, base) + f, err := os.Create(path) + if err != nil { + log.Printf(" warning: health probe: %v", err) + return "" + } + defer f.Close() + + command := diagCommand(basePort, prog, remoteRoot) + targets := healthyPingTargets(allHosts, implicated, healthProbePeers) + + var mu sync.Mutex + run(withTimeout(sub, healthProbeTimeout), "health probe", func(ctx context.Context, host iago.Host) error { + out, shErr := iago.Output(ctx, host, command) + mu.Lock() + defer mu.Unlock() + fmt.Fprintf(f, "===== %s =====\n", host.Name()) + if shErr != nil { + fmt.Fprintf(f, "(probe failed: %v)\n\n", shErr) + return nil + } + io.WriteString(f, out) + fmt.Fprintln(f) + for _, target := range targets { + pingOut, pingErr := iago.Output(ctx, host, healthProbeCommand(target)) + if pingErr != nil { + continue + } + fmt.Fprintf(f, "--- ping %s ---\n%s\n", target, strings.TrimRight(pingOut, "\n")) + } + fmt.Fprintln(f) + return nil + }) + return path +} diff --git a/benchkit/cmd/sweep/health_test.go b/benchkit/cmd/sweep/health_test.go new file mode 100644 index 00000000..b0e12dc3 --- /dev/null +++ b/benchkit/cmd/sweep/health_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "strings" + "testing" +) + +// TestHealthProbeHosts verifies the host-selection logic that decides which +// hosts get post-incident probed for a given run outcome: the hosts of nodes +// with a missing result file for a failed run, the flagged hosts for a +// degraded run (worst-first, as findDegradedNodes orders them), and none for +// a successful run. +func TestHealthProbeHosts(t *testing.T) { + base := "e1_Q_N3_W1_P0" + nodes := []nodeAssignment{ + {host: "bb2", port: 9000}, + {host: "bb3", port: 9000}, + {host: "bb4", port: 9000}, + } + + tests := []struct { + name string + o runOutcome + want []string + }{ + { + name: "failed run probes hosts with missing result files", + o: runOutcome{ + status: runStatusFailed, + missingFiles: []string{resultFilename(base, nodes[2], resultExt)}, + }, + want: []string{"bb4"}, + }, + { + name: "failed run with multiple missing files dedups by host and preserves node order", + o: runOutcome{ + status: runStatusFailed, + missingFiles: []string{ + resultFilename(base, nodes[2], resultExt), + resultFilename(base, nodes[0], resultExt), + }, + }, + want: []string{"bb2", "bb4"}, + }, + { + name: "failed run with no missing files (e.g. collection-phase failure) probes nothing", + o: runOutcome{status: runStatusFailed}, + want: nil, + }, + { + name: "degraded run probes flagged hosts worst-first", + o: runOutcome{ + status: runStatusDegraded, + degraded: []degradedNode{ + {Host: "bb4:9000", Relative: 0.05}, + {Host: "bb3:9000", Relative: 0.3}, + }, + }, + want: []string{"bb4", "bb3"}, + }, + { + name: "degraded run dedups hosts sharing multiple nodes", + o: runOutcome{ + status: runStatusDegraded, + degraded: []degradedNode{ + {Host: "bb4:9000", Relative: 0.05}, + {Host: "bb4:9001", Relative: 0.1}, + }, + }, + want: []string{"bb4"}, + }, + { + name: "succeeded run probes nothing", + o: runOutcome{status: runStatusSucceeded}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := healthProbeHosts(tt.o, base, nodes) + if len(got) != len(tt.want) { + t.Fatalf("hosts = %v, want %v", got, tt.want) + } + for i, h := range got { + if h != tt.want[i] { + t.Errorf("hosts[%d] = %q, want %q", i, h, tt.want[i]) + } + } + }) + } +} + +// TestHealthyPingTargets verifies that the ping-ring bonus check targets up +// to n hosts not in the implicated set, in allHosts order, and that an +// implicated host is never chosen as its own health check target. +func TestHealthyPingTargets(t *testing.T) { + allHosts := []hostAssignment{ + {alias: "bb2"}, + {alias: "bb3", peerHost: "152.94.162.13"}, + {alias: "bb4"}, + {alias: "bb5"}, + } + + got := healthyPingTargets(allHosts, map[string]bool{"bb2": true}, 2) + want := []string{"152.94.162.13", "bb4"} // bb3's peer address, then bb4 + if len(got) != len(want) { + t.Fatalf("targets = %v, want %v", got, want) + } + for i, target := range want { + if got[i] != target { + t.Errorf("targets[%d] = %q, want %q", i, got[i], target) + } + } + + if got := healthyPingTargets(allHosts, map[string]bool{"bb2": true, "bb3": true, "bb4": true, "bb5": true}, 2); got != nil { + t.Errorf("all hosts implicated: targets = %v, want none", got) + } + + if got := healthyPingTargets(allHosts, nil, 1); len(got) != 1 { + t.Errorf("n=1: targets = %v, want exactly 1", got) + } +} + +// TestHealthProbeCommand verifies the ping-ring bonus command is tighter +// (fewer pings, shorter deadline) than netcheck's preflight probe, since it +// runs mid-sweep and must stay cheap, and that the target is shell-quoted. +func TestHealthProbeCommand(t *testing.T) { + cmd := healthProbeCommand("152.94.162.26") + for _, want := range []string{"ping", "-c 5", "-w 2", "-q", "'152.94.162.26'"} { + if !strings.Contains(cmd, want) { + t.Errorf("command missing %q\ngot: %s", want, cmd) + } + } +} diff --git a/benchkit/cmd/sweep/heatmap.go b/benchkit/cmd/sweep/heatmap.go new file mode 100644 index 00000000..5c0437e7 --- /dev/null +++ b/benchkit/cmd/sweep/heatmap.go @@ -0,0 +1,229 @@ +package main + +import ( + "cmp" + "slices" + "strconv" + + "github.com/relab/gorums/benchkit" + "golang.org/x/exp/stats" +) + +// nodeHealthRecord summarizes one host's throughput relative to its run median +// across repetitions and configurations with the same benchmark, node count, +// and stream mode. A healthy cluster is uniform near 1.0; a host behind a slow +// link or a faulty host shows as a low value, so the heatmap exposes it at a +// glance without producing one column per repetition. +type nodeHealthRecord struct { + benchkit.Dimensions + host string + throughput float64 + rel float64 + runs int +} + +// nodeHealthRows first reduces the per-node CDF rows to one throughput per node +// per run, then divides by that run's median node throughput. It finally takes +// the median of those relative values for each benchmark/node-count/mode/host +// combination. A run whose median is zero is treated as uniform (rel 1.0) +// rather than producing NaN. Rows are sorted by benchmark, mode, node count, +// then host in natural order (bb2 before bb10). +func nodeHealthRows(cdf []plotNodeCDFRecord) []nodeHealthRecord { + type key struct{ base, benchmark, node string } + first := make(map[key]plotNodeCDFRecord) + var order []key + for _, r := range cdf { + k := key{r.base, r.Benchmark, r.node} + if _, ok := first[k]; ok { + continue + } + first[k] = r + order = append(order, k) + } + type runKey struct{ base, benchmark string } + byRun := make(map[runKey][]float64) + for _, k := range order { + rec := first[k] + rk := runKey{k.base, k.benchmark} + byRun[rk] = append(byRun[rk], rec.throughput) + } + medians := make(map[runKey]float64, len(byRun)) + for k, xs := range byRun { + if len(xs) > 0 { + medians[k] = stats.Median(xs) + } + } + + type summaryKey struct { + benchkit.Dimensions + host string + } + type summary struct { + throughput []float64 + rel []float64 + } + summaries := make(map[summaryKey]*summary) + for _, k := range order { + rec := first[k] + rk := runKey{rec.base, rec.Benchmark} + if len(byRun[rk]) < 2 { + continue + } + sk := summaryKey{ + Dimensions: nodeHealthDimensions(rec.Dimensions), + host: hostFromAddr(rec.node), + } + s := summaries[sk] + if s == nil { + s = &summary{} + summaries[sk] = s + } + s.throughput = append(s.throughput, rec.throughput) + if m := medians[rk]; m > 0 { + s.rel = append(s.rel, rec.throughput/m) + } else { + s.rel = append(s.rel, 1.0) + } + } + + out := make([]nodeHealthRecord, 0, len(summaries)) + for key, s := range summaries { + out = append(out, nodeHealthRecord{ + Dimensions: key.Dimensions, + host: key.host, + throughput: stats.Median(s.throughput), + rel: stats.Median(s.rel), + runs: len(s.rel), + }) + } + slices.SortFunc(out, func(a, b nodeHealthRecord) int { + return cmp.Or( + compareDimensions(a.Dimensions, b.Dimensions), + compareHost(a.host, b.host), + ) + }) + return out +} + +// compareHost orders hosts naturally so a numeric suffix sorts by value +// (bb2 before bb10) rather than lexically. +func compareHost(a, b string) int { + ap, an := splitTrailingNum(a) + bp, bn := splitTrailingNum(b) + if ap != bp { + return cmp.Compare(ap, bp) + } + return cmp.Compare(an, bn) +} + +// splitTrailingNum splits a host into its non-numeric prefix and trailing +// integer (0 when absent), e.g. "bb10" -> ("bb", 10). +func splitTrailingNum(s string) (string, int) { + i := len(s) + for i > 0 && s[i-1] >= '0' && s[i-1] <= '9' { + i-- + } + if i == len(s) { + return s, 0 + } + n, _ := strconv.Atoi(s[i:]) + return s[:i], n +} + +// writeNodeHealthCSV writes the per-host relative-throughput summaries. The col +// field is the compact configuration label the heatmap uses as its column axis, +// naming only the dimensions that vary across the rows. +func writeNodeHealthCSV(path string, rows []nodeHealthRecord) error { + configs := make([]benchkit.Dimensions, len(rows)) + for i, r := range rows { + configs[i] = r.Dimensions + } + varying := varyingDimensions(configs) + return writeCSV(path, + append(dimensionColumns("workers", "payload", "rate", "send_buffer", "recv_buffer"), + "col", "host", "throughput", "rel", "runs"), + rows, func(r nodeHealthRecord) []string { + return append(dimensionValues(r.Dimensions, "workers", "payload", "rate", "send_buffer", "recv_buffer"), + []string{cmp.Or(configLabel(r.Dimensions, varying), "all"), + r.host, formatFloat(r.throughput), formatFloat(r.rel), strconv.Itoa(r.runs), + }...) + }) +} + +// degradedShareRecord is the fraction of a configuration's repetitions flagged +// degraded, kept per stream mode: systematic degradation concentrated in one +// mode is itself a benchkit. +type degradedShareRecord struct { + benchkit.Dimensions + total int + degraded int + share float64 +} + +// degradedShareRows counts, per configuration, how many repetitions were +// flagged degraded out of all that completed (succeeded plus degraded), so a +// heatmap can show where degradation concentrates. It works from the per-rep +// records because a configuration whose every repetition degraded is dropped +// from the rep-averaged table yet still belongs in this diagnostic. +func degradedShareRows(runs []plotRunRecord) []degradedShareRecord { + counts := make(map[benchkit.Dimensions]*degradedShareRecord) + for _, r := range runs { + k := r.Dimensions + c := counts[k] + if c == nil { + c = °radedShareRecord{Dimensions: r.Dimensions} + counts[k] = c + } + c.total++ + if r.status == runStatusDegraded { + c.degraded++ + } + } + out := make([]degradedShareRecord, 0, len(counts)) + for _, c := range counts { + if c.total > 0 { + c.share = float64(c.degraded) / float64(c.total) + } + out = append(out, *c) + } + slices.SortFunc(out, func(a, b degradedShareRecord) int { + return compareDimensions(a.Dimensions, b.Dimensions) + }) + return out +} + +// degradedShareRowDims are the dimensions the degraded-share heatmap puts on +// its row axis: the cluster size and the stream mode, whose horizontal labels +// stay readable. Every other varying dimension goes on the column axis, which +// spreads one long label per configuration over two axes instead of one. +var degradedShareRowDims = map[string]bool{"benchmark": true, "nodes": true, "stream_mode": true} + +// writeDegradedShareCSV writes the degraded-fraction rows with the compact row +// and column labels the heatmap uses as its axes. Both name only the dimensions +// that vary across the rows, so a sweep that held the worker count and the +// buffer capacities fixed does not repeat them in every label. +func writeDegradedShareCSV(path string, rows []degradedShareRecord) error { + configs := make([]benchkit.Dimensions, len(rows)) + for i, r := range rows { + configs[i] = r.Dimensions + } + varying := varyingDimensions(configs) + rowDims, colDims := map[string]bool{}, map[string]bool{} + for name := range varying { + if degradedShareRowDims[name] { + rowDims[name] = true + } else { + colDims[name] = true + } + } + return writeCSV(path, + append(dimensionColumns(), "row", "col", "total", "degraded", "share"), + rows, func(r degradedShareRecord) []string { + return append(dimensionValues(r.Dimensions), + []string{ + cmp.Or(configLabel(r.Dimensions, rowDims), "all"), + cmp.Or(configLabel(r.Dimensions, colDims), "all"), + strconv.Itoa(r.total), strconv.Itoa(r.degraded), formatFloat(r.share), + }...) + }) +} diff --git a/benchkit/cmd/sweep/heatmap_test.go b/benchkit/cmd/sweep/heatmap_test.go new file mode 100644 index 00000000..de91c814 --- /dev/null +++ b/benchkit/cmd/sweep/heatmap_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "maps" + "math" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/relab/gorums/benchkit" +) + +// csvHeader reads the first line of a CSV written to path. +func csvHeader(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + line, _, _ := strings.Cut(string(data), "\n") + return line +} + +func TestNodeHealthRows(t *testing.T) { + // One run, three nodes; bb10 at half the others' throughput. Two CDF rows + // per node prove throughput is read once per node, not per row. + var cdf []plotNodeCDFRecord + for _, n := range []struct { + node string + thr float64 + }{{"bb2:9000", 100}, {"bb3:9000", 100}, {"bb10:9000", 50}} { + for range 2 { + cdf = append(cdf, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{Benchmark: "Q", StreamMode: "dual", Nodes: 3}, + base: "run_Q_N3", label: "run", node: n.node, throughput: n.thr, + }) + } + } + rows := nodeHealthRows(cdf) + if len(rows) != 3 { + t.Fatalf("rows = %d, want 3", len(rows)) + } + // Median of {100,100,50} is 100. Natural host order: bb2, bb3, bb10. + if rows[0].host != "bb2" || rows[2].host != "bb10" { + t.Errorf("host order = %s..%s, want bb2..bb10", rows[0].host, rows[2].host) + } + byHost := map[string]float64{} + for _, r := range rows { + byHost[r.host] = r.rel + } + if byHost["bb2"] != 1.0 || byHost["bb10"] != 0.5 { + t.Errorf("rel bb2=%g bb10=%g, want 1.0 and 0.5", byHost["bb2"], byHost["bb10"]) + } + + path := filepath.Join(t.TempDir(), "node_health.csv") + if err := writeNodeHealthCSV(path, rows); err != nil { + t.Fatal(err) + } + if h := csvHeader(t, path); !strings.Contains(h, "col") || !strings.Contains(h, "rel") { + t.Errorf("node_health header = %q", h) + } +} + +// TestNodeHealthRowsZeroMedianTreatedAsUniform verifies that a run whose +// nodes all report zero throughput (so the run's median is zero) is treated +// as uniform (rel 1.0) instead of dividing by zero and propagating NaN. +func TestNodeHealthRowsZeroMedianTreatedAsUniform(t *testing.T) { + var cdf []plotNodeCDFRecord + for _, node := range []string{"bb2:9000", "bb3:9000"} { + cdf = append(cdf, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{Benchmark: "Q", StreamMode: "dual", Nodes: 2}, + base: "run_Q_N2", label: "run", node: node, throughput: 0, + }) + } + rows := nodeHealthRows(cdf) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + for _, r := range rows { + if math.IsNaN(r.rel) { + t.Fatalf("host %s: rel is NaN, want 1.0 (zero-median run treated as uniform)", r.host) + } + if r.rel != 1.0 { + t.Errorf("host %s: rel = %g, want 1.0", r.host, r.rel) + } + } +} + +// TestNodeHealthRowsSkipsSingleNodeRuns verifies that a run with only one +// node contributing a CDF row is excluded entirely: a lone node has no peers +// to compute a relative-to-median health signal against. +func TestNodeHealthRowsSkipsSingleNodeRuns(t *testing.T) { + cdf := []plotNodeCDFRecord{ + { + Dimensions: benchkit.Dimensions{Benchmark: "Q", StreamMode: "dual", Nodes: 1}, + base: "run_Q_N1", label: "run", node: "bb2:9000", throughput: 100, + }, + } + if rows := nodeHealthRows(cdf); len(rows) != 0 { + t.Errorf("rows = %d, want 0 (single-node run must be skipped)", len(rows)) + } +} + +func TestDegradedShareRows(t *testing.T) { + runs := []plotRunRecord{ + {Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 3, Workers: 8, StreamMode: "dedup"}, status: runStatusSucceeded}, + {Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 3, Workers: 8, StreamMode: "dedup"}, status: runStatusSucceeded}, + {Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 3, Workers: 8, StreamMode: "dedup"}, status: runStatusDegraded}, + {Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 3, Workers: 8, StreamMode: "dual"}, status: runStatusSucceeded}, + } + rows := degradedShareRows(runs) + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) + } + share := map[string]float64{} + for _, r := range rows { + share[r.StreamMode] = r.share + } + // dedup: 1 of 3 degraded; dual: 0 of 1. + if share["dedup"] < 0.33 || share["dedup"] > 0.34 { + t.Errorf("dedup share = %g, want ~0.333", share["dedup"]) + } + if share["dual"] != 0 { + t.Errorf("dual share = %g, want 0", share["dual"]) + } + + path := filepath.Join(t.TempDir(), "degraded_share.csv") + if err := writeDegradedShareCSV(path, rows); err != nil { + t.Fatal(err) + } + if h := csvHeader(t, path); !strings.Contains(h, "share") || !strings.Contains(h, "col") { + t.Errorf("degraded_share header = %q", h) + } +} + +// TestWriteDegradedShareCSVAxisLabels verifies the heatmap's two axis labels: the +// node count and mode go on the row axis and the remaining varying dimensions on +// the column axis, and neither repeats a dimension every configuration shares — +// one long label per configuration on a single axis overlapped the grid. +func TestWriteDegradedShareCSVAxisLabels(t *testing.T) { + var runs []plotRunRecord + for _, nodes := range []int{9, 15} { + for _, rate := range []int{1000, 2000} { + for _, mode := range []string{"dedup", "dual"} { + runs = append(runs, plotRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: nodes, Workers: 32, Payload: 16384, + Rate: rate, SendBuffer: 4096, StreamMode: mode, + }, + status: runStatusSucceeded, + }) + } + } + } + path := filepath.Join(t.TempDir(), "degraded_share.csv") + if err := writeDegradedShareCSV(path, degradedShareRows(runs)); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + rows := strings.Split(strings.TrimSpace(string(data)), "\n") + header := strings.Split(rows[0], ",") + rowCol, colCol := slices.Index(header, "row"), slices.Index(header, "col") + if rowCol < 0 || colCol < 0 { + t.Fatalf("degraded_share header %q lacks row/col", rows[0]) + } + labels := map[string]bool{} + for _, line := range rows[1:] { + fields := strings.Split(line, ",") + labels[fields[rowCol]+" | "+fields[colCol]] = true + } + for _, want := range []string{"N9 dedup | R1000", "N15 dual | R2000"} { + if !labels[want] { + t.Errorf("missing axis labels %q; got %v", want, slices.Sorted(maps.Keys(labels))) + } + } + for label := range labels { + for _, fixed := range []string{"W32", "P16384", "SB4096", "Q"} { + if strings.Contains(label, fixed) { + t.Errorf("label %q repeats %q, which every configuration shares", label, fixed) + } + } + } +} + +func TestSplitTrailingNum(t *testing.T) { + tests := []struct { + in string + prefix string + num int + }{ + {"bb10", "bb", 10}, {"bb2", "bb", 2}, {"rack1-node5", "rack1-node", 5}, {"host", "host", 0}, + } + for _, tt := range tests { + if p, n := splitTrailingNum(tt.in); p != tt.prefix || n != tt.num { + t.Errorf("splitTrailingNum(%q) = (%q,%d), want (%q,%d)", tt.in, p, n, tt.prefix, tt.num) + } + } +} diff --git a/benchkit/cmd/sweep/listflag.go b/benchkit/cmd/sweep/listflag.go new file mode 100644 index 00000000..2c52d1ef --- /dev/null +++ b/benchkit/cmd/sweep/listflag.go @@ -0,0 +1,68 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "strconv" + "strings" +) + +// listFlag is a flag.Value that parses a comma-separated list into a slice, +// converting each element with parse. It backs flags such as -n 1,3,5 and +// -benchmarks Symmetric,Async. Setting the flag replaces the slice wholesale, +// so the destination's initial contents act as the default. +// +// See the flag package's interval example for the single-value analogue: +// https://pkg.go.dev/flag#example-FlagSet +type listFlag[T any] struct { + dst *[]T + parse func(string) (T, error) +} + +// intListFlag returns a flag.Value parsing a comma-separated list of integers +// into dst. +func intListFlag(dst *[]int) flag.Value { + return &listFlag[int]{dst: dst, parse: strconv.Atoi} +} + +// stringListFlag returns a flag.Value parsing a comma-separated list of +// (trimmed, non-empty) strings into dst. +func stringListFlag(dst *[]string) flag.Value { + return &listFlag[string]{dst: dst, parse: func(s string) (string, error) { return s, nil }} +} + +// String renders the current list the way it would be entered on the command +// line, which the flag package uses to show the default value in usage text. +func (l *listFlag[T]) String() string { + if l == nil || l.dst == nil { + return "" + } + parts := make([]string, len(*l.dst)) + for i, v := range *l.dst { + parts[i] = fmt.Sprint(v) + } + return strings.Join(parts, ",") +} + +// Set parses a comma-separated value, trimming whitespace and skipping empty +// fields. It requires at least one valid element so an empty range is rejected. +func (l *listFlag[T]) Set(value string) error { + var out []T + for f := range strings.SplitSeq(value, ",") { + f = strings.TrimSpace(f) + if f == "" { + continue + } + v, err := l.parse(f) + if err != nil { + return err + } + out = append(out, v) + } + if len(out) == 0 { + return errors.New("requires at least one value") + } + *l.dst = out + return nil +} diff --git a/benchkit/cmd/sweep/llm.go b/benchkit/cmd/sweep/llm.go new file mode 100644 index 00000000..edc55b0c --- /dev/null +++ b/benchkit/cmd/sweep/llm.go @@ -0,0 +1,256 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "os" + "strings" +) + +// Provider identifiers accepted by -explain-provider. Each maps to a distinct +// client because the endpoints differ in both path and wire format: local uses +// Ollama's native /api/chat API (the UiS server is proxied so that only the +// /api/ routes are reachable; the OpenAI-compatible /v1 path returns an empty +// 200 there), openai uses the OpenAI /v1/chat/completions API, and claude uses +// the Anthropic Messages API. +const ( + providerLocal = "local" + providerOpenAI = "openai" + providerClaude = "claude" +) + +// Default API endpoints per provider; each provider's Diagnose appends the path +// its API expects. +const ( + defaultLocalEndpoint = "https://ollama.ux.uis.no" + defaultOpenAIEndpoint = "https://api.openai.com" + defaultClaudeEndpoint = "https://api.anthropic.com" +) + +// Environment variables holding each provider's API key. Keys are read from the +// environment only, never from flags or disk, so they are not committed. +const ( + envLocalKey = "OLLAMA_API_KEY" + envOpenAIKey = "OPENAI_API_KEY" + envClaudeKey = "ANTHROPIC_API_KEY" +) + +// anthropicVersion is the required Anthropic API version header value. +const anthropicVersion = "2023-06-01" + +// maxResponseTokens bounds the diagnosis length; a short plain-text verdict +// fits comfortably. +const maxResponseTokens = 1024 + +// llmProvider sends a system+user prompt to a configured model and returns the +// model's plain-text reply. The context bounds the request. +type llmProvider interface { + Diagnose(ctx context.Context, system, user string) (string, error) +} + +// newProvider builds the provider selected by cfg.explainProvider, reading the +// API key from the provider's environment variable. It fails if the provider is +// unknown, the model is empty, or the key variable is unset. +func newProvider(cfg *config) (llmProvider, error) { + if cfg.explainModel == "" { + return nil, fmt.Errorf("-explain-model is required (e.g. -explain-model llama3.3)") + } + key, err := requireKey(providerKeyEnv(cfg.explainProvider)) + if err != nil { + return nil, err + } + switch cfg.explainProvider { + case providerLocal: + return &ollamaProvider{chatClient{baseURL: defaultLocalEndpoint, apiKey: key, model: cfg.explainModel, client: http.DefaultClient}}, nil + case providerOpenAI: + return &openAIProvider{chatClient{baseURL: defaultOpenAIEndpoint, apiKey: key, model: cfg.explainModel, client: http.DefaultClient}}, nil + case providerClaude: + return &anthropicProvider{chatClient{baseURL: defaultClaudeEndpoint, apiKey: key, model: cfg.explainModel, client: http.DefaultClient}}, nil + default: + return nil, fmt.Errorf("unknown -explain-provider %q (use local, openai, or claude)", cfg.explainProvider) + } +} + +// providerKeyEnv returns the environment variable holding the given provider's +// API key. An unknown provider maps to the local key; newProvider rejects the +// provider itself, so the caller still gets a clear error. +func providerKeyEnv(provider string) string { + switch provider { + case providerOpenAI: + return envOpenAIKey + case providerClaude: + return envClaudeKey + default: + return envLocalKey + } +} + +// requireKey reads an API key from the named environment variable, failing with +// a message that names the variable when it is unset or empty. +func requireKey(name string) (string, error) { + key := os.Getenv(name) + if key == "" { + return "", fmt.Errorf("%s is not set; export your API key in that environment variable", name) + } + return key, nil +} + +// chatClient holds the endpoint, credentials, model, and HTTP client shared by +// every provider. Each provider embeds it and differs only in the request path, +// wire format, and response shape. +type chatClient struct { + baseURL string + apiKey string + model string + client *http.Client +} + +// ollamaProvider talks to Ollama's native /api/chat endpoint. It takes the same +// role/content messages as the OpenAI API but replies with a single message +// object rather than a choices array, and lives under /api/ so it works through +// the UiS server's proxy, which does not forward the OpenAI-compatible /v1 path. +type ollamaProvider struct{ chatClient } + +func (p *ollamaProvider) Diagnose(ctx context.Context, system, user string) (string, error) { + reqBody := chatReqBody(p.model, system, user) + var respBody struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } + url := strings.TrimRight(p.baseURL, "/") + "/api/chat" + header := http.Header{"Authorization": {"Bearer " + p.apiKey}} + if err := postJSON(ctx, p.client, url, header, reqBody, &respBody); err != nil { + return "", err + } + content := strings.TrimSpace(respBody.Message.Content) + if content == "" { + return "", fmt.Errorf("model returned an empty message") + } + return content, nil +} + +// openAIProvider talks to an OpenAI-compatible /v1/chat/completions endpoint, +// used by the openai provider (and any other host that speaks that wire format). +type openAIProvider struct{ chatClient } + +func (p *openAIProvider) Diagnose(ctx context.Context, system, user string) (string, error) { + reqBody := chatReqBody(p.model, system, user) + var respBody struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + url := strings.TrimRight(p.baseURL, "/") + "/v1/chat/completions" + header := http.Header{"Authorization": {"Bearer " + p.apiKey}} + if err := postJSON(ctx, p.client, url, header, reqBody, &respBody); err != nil { + return "", err + } + if len(respBody.Choices) == 0 { + return "", fmt.Errorf("model returned no choices") + } + return strings.TrimSpace(respBody.Choices[0].Message.Content), nil +} + +// anthropicProvider talks to the Anthropic Messages API (/v1/messages). The +// system prompt is a top-level field; the user prompt is the sole message. +type anthropicProvider struct{ chatClient } + +func (p *anthropicProvider) Diagnose(ctx context.Context, system, user string) (string, error) { + reqBody := map[string]any{ + "model": p.model, + "max_tokens": maxResponseTokens, + "system": system, + "messages": []map[string]string{ + {"role": "user", "content": user}, + }, + } + var respBody struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + url := strings.TrimRight(p.baseURL, "/") + "/v1/messages" + header := http.Header{ + "X-Api-Key": {p.apiKey}, + "Anthropic-Version": {anthropicVersion}, + } + if err := postJSON(ctx, p.client, url, header, reqBody, &respBody); err != nil { + return "", err + } + if len(respBody.Content) == 0 { + return "", fmt.Errorf("model returned no content") + } + return strings.TrimSpace(respBody.Content[0].Text), nil +} + +// chatReqBody builds the request body shared by the Ollama and OpenAI chat +// APIs: the model plus a system and user message, with streaming disabled. +func chatReqBody(model, system, user string) map[string]any { + return map[string]any{ + "model": model, + "messages": []map[string]string{ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + }, + "stream": false, + } +} + +// postJSON marshals body to JSON, POSTs it to url with Content-Type and the +// extra headers, and decodes a successful JSON reply into out. A non-2xx +// response is returned as an error including the response body for diagnosis. +func postJSON(ctx context.Context, client *http.Client, url string, header http.Header, body, out any) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + maps.Copy(req.Header, header) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response (%s): %w", resp.Status, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s: %s", resp.Status, bodySnippet(data)) + } + // A 2xx with an empty or non-JSON body is the failure that "unexpected end of + // JSON input" used to hide: name the status, the body length, and a snippet so + // the cause (empty reply, gateway error page, truncated stream) is visible in + // the log without re-running the failing scenario. + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decoding response (%s, %d-byte body): %w; body: %q", resp.Status, len(data), err, bodySnippet(data)) + } + return nil +} + +// maxBodySnippet bounds the response-body excerpt included in error messages so +// a multi-kilobyte error page does not flood the log. +const maxBodySnippet = 512 + +// bodySnippet returns a trimmed, length-bounded rendering of an HTTP response +// body for error messages. An empty body renders as the empty string, so a +// 0-byte reply is unambiguous in the message. +func bodySnippet(data []byte) string { + s := strings.TrimSpace(string(data)) + if len(s) > maxBodySnippet { + return fmt.Sprintf("%s... [+%d bytes]", s[:maxBodySnippet], len(s)-maxBodySnippet) + } + return s +} diff --git a/benchkit/cmd/sweep/main.go b/benchkit/cmd/sweep/main.go new file mode 100644 index 00000000..9a298b39 --- /dev/null +++ b/benchkit/cmd/sweep/main.go @@ -0,0 +1,899 @@ +// Command sweep runs a distributed parameter sweep of the gorums benchmark +// across a cluster of machines reachable via SSH. +// +// Usage: +// +// sweep [flags] +// +// Host selection: +// +// The -hosts flag is required. It accepts a comma-separated list of SSH host +// aliases where each token may be: +// +// - A PREFIX[lo-hi]SUFFIX numeric range expanded to one alias per integer, +// e.g. "bb[1-30]" → bb1, bb2, …, bb30. +// - A glob pattern (*, ?) matched against non-wildcard Host entries in the +// SSH config, e.g. "bb*" enumerates explicit bb1, bb2, … entries. +// - A literal alias returned verbatim. +// +// For example: +// +// -hosts 'bb[1-30]' bb1, bb2, ..., bb30 +// -hosts 'bb[1-7],nebula' bb1..bb7 plus nebula +// -hosts 'rack[1-2]-node' rack1-node, rack2-node +// -hosts 'bb*' all explicit bb* entries in SSH config +// +// Quote the value so the shell does not interpret the brackets. +// +// The -check diagnostic mode operates on the selected hosts and then exits: +// +// -check report reachability, host info, busy benchmark ports, lingering +// benchmark processes, and clock skew, using the sweep's SSH path +// +// Any binary deployed via -binary must support these flags: +// +// -self=host:port this node's listen address (distributed mode) +// -remotes=addr,... comma-separated peer addresses +// -benchmarks=name,... comma-separated exact benchmark names to run +// -workers=N concurrent goroutines +// -payload=N payload size in bytes +// -time=duration measurement duration +// -output=path write result file to this path +// -rate=N target sends/sec per node; 0 = unlimited (optional) +// -stream-mode=dual|dedup symmetric stream topology (optional; default dual) +// -verbose verbose logging (optional) +// +// For a foreign protocol, pass a prebuilt linux/amd64 binary via -binary; sweep +// uploads and runs it unchanged. When -binary is omitted, sweep builds the +// binary itself: it uses the -build command (or the BENCHKIT_BUILD environment +// variable) if set, substituting the chosen output path for the {{output}} +// token, and otherwise falls back to the built-in default +// "go build -o ./cmd/benchmark". The custom command is run via "sh -c" +// from the benchkit module root with GOOS=linux GOARCH=amd64 in its environment. +// +// Cluster-local driver: +// +// When the controlling laptop is far from the cluster, every per-run SSH +// round-trip and the one-time binary upload to each host cross the WAN. The +// -driver flag moves the orchestration onto a cluster-local host so that +// traffic stays on the LAN and the benchmark binary crosses the WAN only once: +// +// -driver run the orchestration on this SSH host (LAN-local to peers) +// -driver first use the first -hosts entry as the driver +// +// The driver host is excluded from the benchmark pool so the orchestrator does +// not perturb a co-located replica; a -driver host outside -hosts (a dedicated +// head node) leaves the pool unchanged. The laptop cross-builds sweep and the +// benchmark binary, ships them plus a generated SSH config to the driver over +// the user's own ssh/scp, and re-execs sweep there with -driven. The SSH agent +// is forwarded (ssh -A) so the driver authenticates to peers with the laptop's +// keys. The remote sweep is detached, so it survives a laptop disconnect; by +// default the laptop streams its output live and, on completion, downloads +// compact plot data plus any failed-run result files. Raw successful .binpb +// files stay in the driver work dir for optional archival. Reconnect to a +// detached run, or archive raw results after compact collection, with: +// +// -collect [remote-work-dir] +// +// -detach skips the streaming and waiting: the run starts, the launcher +// records the run in /.sweep-last.json and exits, so closing the +// laptop lid right away is an intentional clean exit rather than a dropped +// connection: +// +// -driver -detach +// +// A bare -collect uses the saved driver and path for the latest launch. +// -collect-now [path] takes a best-effort snapshot even while a run is active, +// and -list shows active, completed, raw-pending, and recoverable driver runs. +// Remote files live below /sweep-$USER (default root: /tmp). +// +// A run whose compact transfer was built before a change to what gets exported +// can be rebuilt where its raw files still are, then downloaded again: +// +// -export-compact rebuild plotdata/ and compact-transfer/, then exit +// +// The -driven and -git-sha flags are internal: the launcher sets them on the +// driver and they are not meant to be passed by hand. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/relab/iago" +) + +// config holds all runtime configuration. It is internal to the sweep tool. +type config struct { + binaryPath string + buildCmd string + sshConfig string + rootDir string // -outdir: root directory that holds per-run subdirectories + outDir string // actual run directory under rootDir + port int + duration time.Duration + trim time.Duration + sweepLabel string + verbose bool + check bool + sweep sweepConfig + prog remoteProgram + + // Pass-through flags forwarded verbatim to every benchmark node. + interval string // -interval; empty = binary default + statsMode string // -stats-mode; empty = binary default + rateStep int // -rate-step; 0 = no ramp + rateStepMax int // -rate-step-max; 0 = no ramp + extraArgs string // -extra-args; appended verbatim + + collectProfiles bool // -collect-profiles: gather per-node CPU/heap profiles + pgo bool // -pgo: merge collected CPU profiles into default.pgo + + // -plot: (re)generate the Typst report from a collected output directory, + // without running a sweep. The remaining fields filter that report. + plotDir string // -plot: output directory to render; empty = run a sweep + includeDegraded bool // -include-degraded: keep degraded reps in the aggregates + excludeRuns []string // -exclude-run: run base names to drop + excludeDims []string // -exclude: DIM=VALUE tokens to drop (e.g. nodes=58) + + // exportCSVDir: regenerate the human- and grep-friendly runs.csv/nodes.csv + // pair from a collected directory's plotdata.binpb, without running a + // sweep or generating a report (see exportPlotCSV). + exportCSVDir string // -export-csv: output directory whose plotdata.binpb to export; empty = do not export + + // exportCompactDir: rebuild the reduced plot data, the event streams, and + // the compact-transfer directory from a sweep work directory's raw result + // files (see prepareCompactTransfer). This is the driver-side half of a + // download: it runs where the raw files are, so a run whose compact transfer + // predates a change to what gets exported can be rebuilt and re-downloaded + // without shipping the raw archive across the WAN. + exportCompactDir string // -export-compact: sweep work directory to rebuild; empty = do not rebuild + + // The degradation bounds each node's measurement must respect, relative to + // the run median (see degraded.go); a non-positive value disables that + // check. + degradedBelow float64 // -degraded-below: minimum throughput + degradedAbove float64 // -degraded-above: maximum throughput + degradedLatencyBelow float64 // -degraded-latency-below: minimum median latency + + // netcheck probes every host link with a ping ring before the sweep and + // aborts on heavy packet loss (see netcheck.go). + netcheck bool // -netcheck + + // fdLimit raises the soft open-file limit (ulimit -Sn) for each launched + // benchmark node and, in driven mode, the driver-side sweep process. The + // default lifts the common 1024 soft limit that a large mesh exhausts; 0 + // disables the change and uses the host default (see fdLimitStmt). + fdLimit int // -fd-limit + + // Cluster-local driver: run the orchestration on a host inside the cluster + // so per-run SSH and the binary upload stay on the LAN (see driver.go). + driver string // -driver: driver host alias, or "first" for hosts[0]; "" = run locally + collect string // -collect [path]: collect a finished driver run; no path selects the latest + collectNow string // -collect-now [path]: snapshot a run even when it is still active + list bool // -list: list driver runs and their collection status + remoteDir string // -remote-dir: remote storage root; each user gets sweep-$USER below it + remoteDirs map[string]string + detach bool // -detach: with -driver, start the run and exit immediately + driven bool // -driven (internal): this process is the orchestrator on the driver + gitSHA string // -git-sha (internal): HEAD forwarded by the launcher for manifests + readyMarker string // -ready-marker (internal): path to touch once peers are dialed + selfHost string // -self-host (internal): alias of the host running a -check, probed locally instead of via SSH + transferMode string // -transfer: file transfer backend for driver uploads/downloads (rsync or sftp) + + // LLM failure triage: when -explain is set, the failed runs are diagnosed by + // a model after the sweep completes. With -driver the driven sweep does this + // on the driver before exporting, so the diagnoses travel back in the manifests + // (see explain.go and llm.go). + explain bool // -explain: triage failed runs after the sweep + explainCheck bool // -explain-check: verify the triage LLM responds, then exit + explainProvider string // -explain-provider: local, openai, or claude + explainModel string // -explain-model: model name passed to the provider + explainMaxLog int // -explain-max-log: head+tail byte cap on the node log +} + +func main() { + cfg, hosts := parseFlags() + cfg.prog = newRemoteProgram(cfg.binaryPath) + + // -export-csv regenerates the human- and grep-friendly runs.csv/nodes.csv + // pair from a collected directory's plotdata.binpb and exits. It runs no + // sweep and needs no hosts, so it is handled before any of the + // connection-oriented modes. + if cfg.exportCSVDir != "" { + if err := exportPlotCSV(cfg.exportCSVDir); err != nil { + log.Fatalf("export-csv: %v", err) + } + log.Printf("exported %s", filepath.Join(cfg.exportCSVDir, plotDataDir)) + return + } + + // -export-compact rebuilds a finished run's compact transfer from the raw + // result files still in its work directory and exits. Like -export-csv it + // runs no sweep and needs no hosts, but it runs where the raw files are — + // the driver — so the laptop can then download the rebuilt directory. + if cfg.exportCompactDir != "" { + summary, err := prepareCompactTransfer(cfg.exportCompactDir, cfg.collectProfiles) + if err != nil { + log.Fatalf("export-compact: %v", err) + } + logCompactTransfer(cfg.exportCompactDir, summary) + return + } + + // -plot regenerates the report from a collected output directory and exits. + // It runs no sweep and needs no hosts, so it is handled before any of the + // connection-oriented modes. + if cfg.plotDir != "" { + if err := generateReport(cfg.plotDir, reportOptionsFromConfig(cfg)); err != nil { + log.Fatalf("plot: %v", err) + } + return + } + + // -check exercises the same iago SSH path the sweep uses, so a clean result + // means the sweep will connect. It connects to each host independently, so + // unreachable hosts are reported rather than aborting the whole check. With + // -driver it ships the check to the driver so clock skew is measured against + // the driver's LAN-local clock (the vantage the benchmark's own ClockSync + // uses) instead of the laptop's, whose WAN round-trip otherwise dominates the + // skew estimate and its uncertainty. + if cfg.check { + check := func(*config) error { + return checkHosts(hosts, cfg.sshConfig, cfg.port, cfg.prog, cfg.selfHost, cfg.remoteDir) + } + if cfg.driver != "" && !cfg.driven { + check = func(cfg *config) error { return runDriverCheck(cfg, hosts) } + } + if err := check(cfg); err != nil { + log.Fatalf("check: %v", err) + } + return + } + + // -explain-check verifies the triage LLM answers a trivial prompt and exits, + // so a misconfigured provider/model/key/endpoint is caught on demand instead + // of only when a failed run needs triage. With -driver it ships the check to + // the driver, the only side of the firewall that can reach the UiS Ollama + // server; otherwise it runs locally against a provider the laptop can reach. + if cfg.explainCheck { + check := runExplainCheck + if cfg.driver != "" && !cfg.driven { + check = func(cfg *config) error { return runDriverExplainCheck(cfg, hosts) } + } + if err := check(cfg); err != nil { + log.Fatalf("explain-check: %v", err) + } + return + } + + // Reconnect to a detached driver run: download its results and exit. + if cfg.collect != "" || cfg.collectNow != "" { + if err := runDriverCollect(cfg); err != nil { + log.Fatalf("driver collect: %v", err) + } + return + } + if cfg.list { + if err := runDriverList(cfg); err != nil { + log.Fatalf("driver list: %v", err) + } + return + } + // Launch the orchestration on a cluster-local driver host: build, ship, + // re-exec sweep there with -driven, stream, and collect. The launcher talks + // only to the driver; the driven sweep does all the peer SSH itself. + if cfg.driver != "" && !cfg.driven { + if err := runDriver(cfg, hosts); err != nil { + log.Fatalf("driver: %v", err) + } + return + } + + // The module-root requirement, build, replay script, and stale-binary warning + // are laptop-only steps; the driven sweep on the driver skips them (it runs + // from a temp work dir with a pre-built binary and a forwarded git SHA). + if !cfg.driven { + if err := requireBenchkitModuleRoot(); err != nil { + log.Fatal(err) + } + } + + if err := os.MkdirAll(cfg.outDir, 0o755); err != nil { + log.Fatalf("creating output directory: %v", err) + } + + // Mirror orchestration log output to /sweep.log so a failed sweep + // can be diagnosed after the fact; the console alone is gone once the + // terminal scrolls or closes. Per-node benchmark output does not go here: + // each run streams it to /logs/.log instead (see newRunLogger), + // keeping sweep.log a readable high-level narrative. + sweepLogPath := filepath.Join(cfg.outDir, "sweep.log") + logFile, err := os.Create(sweepLogPath) + if err != nil { + log.Fatalf("creating sweep log: %v", err) + } + defer logFile.Close() + log.SetOutput(io.MultiWriter(os.Stderr, logFile)) + + // The replay script and stale-binary check are laptop-only: on the driver + // os.Args is the driven command (not the user's), there is no repo HEAD, and + // the binary was freshly built by the launcher. The launcher writes the + // replay script locally and forwards the laptop's HEAD via -git-sha. + gitSHA := cfg.gitSHA + if !cfg.driven { + replayScriptPath, err := writeReplayScript(cfg.outDir, os.Args) + if err != nil { + log.Fatalf("creating replay script: %v", err) + } + log.Printf("replay script: %s", replayScriptPath) + + // Catch a sweep binary built before the latest commits: it silently runs + // outdated code, so warn before doing any work. + gitSHA = gitHeadSHA() + warnIfStaleBinary(gitSHA) + } + + // Verify the triage LLM before spending the sweep: a broken -explain is far + // cheaper to fix now than after an hour-long run whose failed runs can no + // longer be easily re-triaged. This runs where triage will run (the driver + // for a driven sweep), so it exercises that host's reachability and key. + if cfg.explain { + if err := runExplainCheck(cfg); err != nil { + log.Fatalf("explain preflight failed: %v\nfix the model or endpoint, or rerun without -explain to skip triage", err) + } + } + + if line := sweepEstimateLine(cfg.sweep, cfg.duration); line != "" { + log.Print(line) + } + + // Build the benchmark binary if a pre-built path was not provided. + // Precedence: -binary (prebuilt) > -build flag > $BENCHKIT_BUILD > the + // built-in gorums default. + if cfg.binaryPath == "" { + cfg.binaryPath = defaultBinaryPath + buildCmd := cfg.buildCmd + if buildCmd == "" { + buildCmd = os.Getenv("BENCHKIT_BUILD") + } + if err := buildBenchmark(cfg.binaryPath, buildCmd); err != nil { + log.Fatalf("build: %v", err) + } + } + binAbs, err := filepath.Abs(cfg.binaryPath) + if err != nil { + log.Fatalf("binary path: %v", err) + } + + // Limit connections to the physical hosts actually needed for this sweep. + maxPhysical := 0 + for params := range cfg.sweep.params() { + maxPhysical = max(maxPhysical, min(params.Nodes, len(hosts))) + } + if maxPhysical < len(hosts) { + hosts = hosts[:maxPhysical] + } + + log.Printf("connecting to %d host(s)...", len(hosts)) + sshConfigPath, err := resolveSSHConfigPath(cfg.sshConfig) + if err != nil { + log.Fatalf("SSH config path: %v", err) + } + sshConfig, err := iago.ParseSSHConfig(sshConfigPath) + if err != nil { + log.Fatalf("SSH config: %v", err) + } + group, err := iago.NewSSHGroup(hosts, cfg.sshConfig) + if err != nil { + log.Fatalf("SSH group: %v", err) + } + defer group.Close() + + // The peer dial above is the only time the driven sweep needs the laptop's + // forwarded SSH agent (all later per-run work reuses these connections). A + // -detach launcher watches for this marker to learn the dial succeeded, so + // it can keep the laptop connected exactly until the forwarded agent is no + // longer needed and then report that it is safe to disconnect. + if cfg.readyMarker != "" { + if err := os.WriteFile(cfg.readyMarker, []byte("dialed\n"), 0o644); err != nil { + log.Printf("warning: could not write ready marker %s: %v", cfg.readyMarker, err) + } + } + + allHosts, err := resolvePeerHosts(group.Hosts, sshConfig.ConnectAddr) + if err != nil { + log.Fatalf("resolve peer hosts: %v", err) + } + log.Printf("resolved peer addresses: %s", peerHostSummary(allHosts)) + cfg.remoteDirs = make(map[string]string, len(group.Hosts)) + for _, host := range group.Hosts { + namespace, err := ensureRemoteNamespace(context.Background(), host, cfg.remoteDir) + if err != nil { + log.Fatalf("remote storage: %v", err) + } + cfg.remoteDirs[host.Name()] = namespace + } + + // A lossy link would not fail any run — TCP retransmits through it — but + // it silently destroys the measurements, so probe every link before + // spending minutes on the sweep. + if cfg.netcheck { + log.Println("checking link health (ping ring)...") + if err := checkNetworkHealth(group, allHosts); err != nil { + log.Fatalf("%v", err) + } + } + + // Kill any lingering processes and deploy a fresh binary. + log.Println("killing lingering processes...") + if err := killLingering(group, cfg.prog); err != nil { + log.Printf("warning: kill: %v", err) + } + if err := upload(group, binAbs, cfg); err != nil { + log.Fatalf("upload: %v", err) + } + log.Println("deployment complete") + + // Execute the sweep. + total := countRuns(cfg.sweep) + log.Printf("starting sweep: %d run(s), output → %s", total, displayPath(cfg.outDir)) + // The static estimate was printed before build and SSH setup. Progress below + // recomputes the same lower/upper bounds from the remaining run count only. + runNum := 0 + // remoteFiles tracks the result files this sweep creates per host so cleanup + // removes only what it produced, not every result file on the cluster. + remoteFiles := make(map[string][]string) + // collectExts are the per-node artifacts downloaded after each run; profile + // artifacts join the result file when -collect-profiles is set. + collectExts := []string{resultExt} + if cfg.collectProfiles { + collectExts = append(collectExts, cpuProfExt, memProfExt) + } + var failedManifests, degradedManifests []string + finalizeRun := func(base string, nodes []nodeAssignment, o runOutcome) { + if err := updateManifestOutcome(cfg.outDir, base, o); err != nil { + log.Printf(" warning: manifest outcome: %v", err) + } + switch o.status { + case runStatusFailed: + failedManifests = append(failedManifests, manifestPath(cfg.outDir, base)) + case runStatusDegraded: + degradedManifests = append(degradedManifests, manifestPath(cfg.outDir, base)) + } + // Post-incident health probe: the implicated host(s) are re-probed + // immediately, while the evidence (load, retransmit counters) is + // still fresh — see health.go. + if hosts := healthProbeHosts(o, base, nodes); len(hosts) > 0 { + if path := runHealthProbe(group, allHosts, cfg.prog, cfg.port, hosts, cfg.outDir, base, cfg.remoteDir); path != "" { + log.Printf(" health probe (%s): %s", strings.Join(hosts, ","), path) + } + } + } + for params := range cfg.sweep.params() { + runNum++ + base := runBase(cfg.sweepLabel, params) + nodes := buildNodeAssignments(allHosts, params.Nodes, cfg.port) + peers := buildPeerList(nodes) + writeManifest(cfg.outDir, base, params, nodes, cfg, gitSHA, binAbs) + for _, n := range nodes { + for _, ext := range collectExts { + remoteFiles[n.host] = append(remoteFiles[n.host], filepath.Join(cfg.remoteDirs[n.host], resultFilename(base, n, ext))) + } + } + + numHosts := min(params.Nodes, len(allHosts)) + sub := group + sub.Hosts = group.Hosts[:numHosts] + + // Progress goes through log so each run is timestamped (stalls are then + // visible in the log) and recorded in sweep.log. + log.Printf("[%d/%d] %-8s N=%-4d workers=%-4d payload=%-6d rate=%-8d stream=%-5s bench=%s", + runNum, total, cfg.sweepLabel, + params.Nodes, params.Workers, params.Payload, params.Rate, params.StreamMode, params.Benchmark) + // Refresh the completion range using only the static per-run bounds. + if runNum > 1 { + log.Print(sweepProgressLine(time.Now(), cfg.duration, runNum-1, total)) + } + + if err := killLingering(sub, cfg.prog); err != nil { + log.Printf(" warning: kill: %v", err) + } + // A node that cannot bind its port stalls the whole run for the full + // readiness deadline; skip the run immediately instead. + if err := checkPortsFree(sub, nodes); err != nil { + log.Printf(" error: %v — skipping run", err) + collectFailureDiag(sub, nodes, cfg.prog, cfg.outDir, base) + collected, missing := countResultFiles(cfg.outDir, base, nodes) + finalizeRun(base, nodes, runOutcome{ + status: runStatusFailed, err: err, failurePhase: failurePhaseSetup, + collectedFiles: collected, missingFiles: missing, + }) + continue + } + // TCP counters are snapshotted around the run so the manifest records + // each host's retransmission/timeout deltas — the evidence that points + // at a lossy link when a run comes out degraded (see tcpstats.go). + tcpBefore := captureTCPStats(sub) + if err := launchAndWait(sub, nodes, peers, params, base, cfg); err != nil { + log.Printf(" error: %v", err) + // Snapshot host and socket state before collecting results: the + // SSH round-trips for collection take seconds, during which + // TIME_WAIT sockets and lingering processes decay. + collectFailureDiag(sub, nodes, cfg.prog, cfg.outDir, base) + tcpStats := diffTCPStats(tcpBefore, captureTCPStats(sub)) + if collectErr := collectRunArtifacts(sub, base, nodes, cfg, collectExts); collectErr != nil { + log.Printf(" warning: partial result collection: %v", collectErr) + } + // Zero result files means the run never reached measurement (a + // launch or AwaitReady failure); a partial set means nodes ran but + // some failed mid-benchmark. + collected, missing := countResultFiles(cfg.outDir, base, nodes) + phase := failurePhaseSetup + if collected > 0 { + phase = failurePhaseMeasurement + } + finalizeRun(base, nodes, runOutcome{ + status: runStatusFailed, err: err, failurePhase: phase, + collectedFiles: collected, missingFiles: missing, tcpStats: tcpStats, + }) + continue + } + tcpStats := diffTCPStats(tcpBefore, captureTCPStats(sub)) + if err := collectRunArtifacts(sub, base, nodes, cfg, collectExts); err != nil { + log.Printf(" error collecting results: %v", err) + collected, missing := countResultFiles(cfg.outDir, base, nodes) + finalizeRun(base, nodes, runOutcome{ + status: runStatusFailed, err: err, failurePhase: failurePhaseCollection, + collectedFiles: collected, missingFiles: missing, tcpStats: tcpStats, + }) + continue + } + collected, missing := countResultFiles(cfg.outDir, base, nodes) + outcome := runOutcome{ + status: runStatusSucceeded, collectedFiles: collected, missingFiles: missing, + tcpStats: tcpStats, + } + // A run whose nodes all completed can still be worthless when one node's + // measurement does not belong with its peers' — it ran far slower (a + // lossy network link), reported far more work than a symmetric benchmark + // allows, or "completed" calls faster than the network permits. Flag it + // so the contaminated aggregate is not silently mixed into the results. + measurements := collectNodeMeasurements(cfg.outDir, base, nodes, cfg.trim) + if deg := findDegradedNodes(measurements, cfg.degradedBounds()); len(deg) > 0 { + outcome.status = runStatusDegraded + outcome.degraded = deg + for _, d := range deg { + log.Printf(" warning: degraded node %v%s", d, degradedTCPSummary(d.Host, tcpStats)) + } + } + finalizeRun(base, nodes, outcome) + } + + // Remove the binary and result files from remote hosts. + log.Println("cleaning up remote hosts...") + if err := cleanup(group, cfg, remoteFiles); err != nil { + log.Printf("warning: cleanup: %v", err) + } + + if cfg.pgo { + if err := mergeCPUProfiles(cfg.outDir); err != nil { + log.Printf("warning: pgo merge: %v", err) + } else { + log.Printf("PGO profile written to %s", displayPath(filepath.Join(cfg.outDir, "default.pgo"))) + } + } + + // Triage failed runs with the LLM before exporting, so the diagnoses land in + // the manifests that the driven sweep packs into compact-transfer (and that a + // local sweep leaves in place). Best-effort: never fail the sweep over a triage error. + if cfg.explain && len(failedManifests) > 0 { + triageFailedRuns(cfg) + } + + if cfg.driven { + summary, err := prepareCompactTransfer(cfg.outDir, cfg.collectProfiles) + if err != nil { + log.Fatalf("compact plot data: %v", err) + } + logCompactTransfer(cfg.outDir, summary) + } + + log.Printf("sweep complete — results in %s", displayPath(cfg.outDir)) + log.Printf("sweep log: %s", displayPath(sweepLogPath)) + log.Printf("per-run node logs: %s", displayPath(filepath.Join(cfg.outDir, logSubdir))) + log.Printf("run manifests: %s", displayPath(filepath.Join(cfg.outDir, "*"+manifestSuffix))) + if len(failedManifests) > 0 { + log.Printf("failed run manifests:") + for _, path := range failedManifests { + printFailedRunArtifacts(cfg.outDir, path) + } + } + if len(degradedManifests) > 0 { + log.Printf("degraded run manifests (a node's measurement did not belong with its peers'; see each manifest's degraded_nodes reason):") + for _, path := range degradedManifests { + printFailedRunArtifacts(cfg.outDir, path) + } + } + // A driven sweep leaves report generation to the laptop that collects it; + // a local sweep has its results in place, so build the report now. + if !cfg.driven { + autoReport(cfg) + } + if cfg.driven { + if err := copyIfExists(sweepLogPath, filepath.Join(cfg.outDir, compactTransferDir, "sweep.log")); err != nil { + log.Printf("warning: refresh compact sweep log: %v", err) + } + } +} + +func collectRunArtifacts(g iago.Group, base string, nodes []nodeAssignment, cfg *config, collectExts []string) error { + err := collectResults(g, base, nodes, cfg, collectExts) + // Bridge collected binary files to protojson for local sweeps. Driven sweeps + // keep the remote output compact by reducing successful runs to plot CSVs; + // if the raw archive is collected later, driver.go regenerates protojson on + // the laptop from the downloaded binpb files. + if !cfg.driven { + convertBinaryResults(cfg.outDir, base, nodes) + } + printRunSummary(cfg.outDir, base, nodes, cfg.trim) + return err +} + +func parseFlags() (*config, []string) { + // Seed the sweep ranges with their defaults; the comma-separated list flags + // below replace a range wholesale when the corresponding flag is given. + cfg := &config{ + sweep: sweepConfig{ + numNodes: []int{9}, + workers: []int{1}, + payloads: []int{0}, + rates: []int{0}, + benchmarks: []string{"SymmetricQuorumCall"}, + streamModes: []string{"dual"}, + reps: 1, + }, + } + flag.StringVar(&cfg.binaryPath, "binary", "", "pre-built linux/amd64 binary path (required for a foreign protocol; default: auto-build)") + flag.StringVar(&cfg.buildCmd, "build", "", "build command for the auto-build path; {{output}} is replaced with the output path (default: go build ./cmd/benchmark; overrides $BENCHKIT_BUILD)") + flag.StringVar(&cfg.sshConfig, "config", "", "SSH config file (default: ~/.ssh/config)") + flag.StringVar(&cfg.rootDir, "outdir", defaultOutRoot, "root output directory for sweep runs (default: out)") + flag.IntVar(&cfg.port, "port", 9000, "base port for benchmark nodes") + flag.DurationVar(&cfg.duration, "duration", 10*time.Second, "measurement duration per run") + flag.DurationVar(&cfg.trim, "trim", 0, "drop interval samples before this offset when summarizing (0 = no trim)") + flag.Float64Var(&cfg.degradedBelow, "degraded-below", 0.5, "flag a run as degraded when a node's throughput falls below this fraction of the run median (0 disables)") + flag.Float64Var(&cfg.degradedAbove, "degraded-above", 2, "flag a run as degraded when a node's throughput exceeds this multiple of the run median, which a symmetric benchmark cannot produce (0 disables)") + flag.Float64Var(&cfg.degradedLatencyBelow, "degraded-latency-below", 0.2, "flag a run as degraded when a node's median latency falls below this fraction of the run median, too fast for the network round trip the benchmark measures (0 disables)") + flag.BoolVar(&cfg.netcheck, "netcheck", true, "probe every host link with a ping ring before the sweep and abort on heavy packet loss") + flag.IntVar(&cfg.fdLimit, "fd-limit", 65536, "raise the soft open-file limit (ulimit -Sn) for launched benchmark nodes and, in driven mode, the driver-side sweep; 0 uses the host default") + flag.StringVar(&cfg.sweepLabel, "sweep", "run", "label prefix for output filenames and, when set explicitly, the run directory name") + flag.BoolVar(&cfg.verbose, "verbose", false, "pass -verbose to benchmark nodes") + flag.BoolVar(&cfg.check, "check", false, "run connectivity and host diagnostics on matched hosts, then exit") + + // Pass-through flags forwarded to every benchmark node (not swept). + flag.StringVar(&cfg.interval, "interval", "", "pass -interval to benchmark nodes (e.g. 250ms, 0 disables events; empty = binary default)") + flag.StringVar(&cfg.statsMode, "stats-mode", "", "pass -stats-mode to benchmark nodes (exact or hdr; empty = binary default)") + flag.IntVar(&cfg.rateStep, "rate-step", 0, "pass -rate-step to benchmark nodes (ops/s per ramp step; 0 = no ramp)") + flag.IntVar(&cfg.rateStepMax, "rate-step-max", 0, "pass -rate-step-max to benchmark nodes (ceiling ops/s; 0 = no ramp)") + flag.StringVar(&cfg.extraArgs, "extra-args", "", "extra flags appended verbatim to every benchmark node command") + flag.BoolVar(&cfg.collectProfiles, "collect-profiles", false, "pass -cpuprofile/-memprofile to every node and download the profiles alongside the result files") + flag.BoolVar(&cfg.pgo, "pgo", false, "merge the collected CPU profiles into /default.pgo for profile-guided optimization (implies -collect-profiles)") + + // Cluster-local driver flags. + flag.StringVar(&cfg.driver, "driver", "", "run the sweep orchestration on this cluster-local SSH host ('first' = first -hosts entry); the binary crosses the WAN once and all per-run SSH stays on the cluster LAN") + flag.Var(optionalPathFlag{&cfg.collect}, "collect", "collect a finished driver run; optional path selects a run, otherwise use the latest run from /.sweep-last.json") + flag.Var(optionalPathFlag{&cfg.collectNow}, "collect-now", "collect a best-effort snapshot now; optional path selects a run, otherwise use the latest run") + flag.BoolVar(&cfg.list, "list", false, "list active, completed, raw-pending, and recoverable driver runs") + flag.StringVar(&cfg.remoteDir, "remote-dir", "/tmp", "remote storage root; sweep creates and uses /sweep-$USER") + flag.BoolVar(&cfg.detach, "detach", false, "with -driver, start the run and exit immediately without streaming or waiting; reconnect later with -driver -collect ") + flag.StringVar(&cfg.transferMode, "transfer", "rsync", "file transfer backend for -driver uploads and downloads: rsync >=3.2.4 (default) or sftp") + flag.BoolVar(&cfg.driven, "driven", false, "internal: set automatically on the cluster-local driver; relaxes laptop-only steps (repo root, build, replay script)") + flag.StringVar(&cfg.gitSHA, "git-sha", "", "internal: repository HEAD forwarded by -driver so the driven sweep records it in manifests") + flag.StringVar(&cfg.readyMarker, "ready-marker", "", "internal: path the driven sweep touches once its peers are dialed, so a -detach launcher knows the forwarded agent is no longer needed") + flag.StringVar(&cfg.selfHost, "self-host", "", "internal: set by a driver-routed -check to the driver's own alias, which is probed locally instead of over SSH (a host cannot SSH to itself)") + + // LLM failure-triage flags. -explain triages the sweep's failed runs after it + // completes; with -driver the driven sweep does it on the driver. The rest + // configure the model backend. API keys come from the environment, never flags. + flag.BoolVar(&cfg.explain, "explain", false, "after the sweep, triage failed runs with an LLM (runs on the driver when -driver is set)") + flag.BoolVar(&cfg.explainCheck, "explain-check", false, "verify the -explain LLM answers a trivial prompt, then exit") + flag.StringVar(&cfg.explainProvider, "explain-provider", providerLocal, "LLM backend for -explain: local (default), openai, or claude") + flag.StringVar(&cfg.explainModel, "explain-model", "", "model name for -explain (e.g. llama3.3, gpt-4o, claude-opus-4-8); required with -explain") + flag.IntVar(&cfg.explainMaxLog, "explain-max-log", defaultExplainMaxLog, "head+tail byte cap on the node log included in the -explain prompt") + + flag.StringVar(&cfg.plotDir, "plot", "", "regenerate the Typst report from this collected output directory, then exit (no sweep)") + flag.BoolVar(&cfg.includeDegraded, "include-degraded", false, "with -plot, keep degraded repetitions in the aggregate figures") + flag.Var(stringListFlag(&cfg.excludeRuns), "exclude-run", "with -plot, comma-separated run base names to drop from the report") + flag.Var(stringListFlag(&cfg.excludeDims), "exclude", "with -plot, comma-separated DIM=VALUE tokens to drop (DIM: benchmark, nodes, workers, payload, rate, send_buffer, recv_buffer, stream_mode)") + flag.StringVar(&cfg.exportCSVDir, "export-csv", "", "regenerate plotdata/runs.csv and plotdata/nodes.csv from this collected directory's plotdata.binpb, then exit (no sweep, no report)") + flag.StringVar(&cfg.exportCompactDir, "export-compact", "", "rebuild plotdata/ and compact-transfer/ from this sweep work directory's raw result files, then exit (run on the driver; -collect-profiles includes the profiles)") + flag.Var(intListFlag(&cfg.sweep.numNodes), "n", "comma-separated node counts to sweep") + flag.Var(intListFlag(&cfg.sweep.workers), "workers", "comma-separated worker counts to sweep") + flag.Var(intListFlag(&cfg.sweep.payloads), "payload", "comma-separated payload sizes in bytes to sweep") + flag.Var(intListFlag(&cfg.sweep.rates), "rate", "comma-separated target sends/sec per node to sweep; 0 = unlimited (saturating)") + flag.Var(intListFlag(&cfg.sweep.sendBuffers), "send-buffer", "comma-separated per-node send queue capacities to sweep (default: the binary's own)") + flag.Var(intListFlag(&cfg.sweep.recvBuffers), "recv-buffer", "comma-separated server receive queue capacities to sweep (default: the binary's own)") + flag.Var(stringListFlag(&cfg.sweep.benchmarks), "benchmarks", "comma-separated benchmark names to run") + flag.Var(stringListFlag(&cfg.sweep.streamModes), "stream-mode", "comma-separated stream modes to sweep: dual,dedup; or baseline alone (requires -binary)") + flag.IntVar(&cfg.sweep.reps, "reps", 1, "repetitions per parameter combination") + + var ( + rawHosts string + testN int + ) + flag.StringVar(&rawHosts, "hosts", "", "SSH host aliases: PREFIX[lo-hi]SUFFIX ranges (e.g. 'bb[1-30]'), glob patterns (e.g. 'bb*'), or comma-separated literals") + flag.IntVar(&testN, "test", 0, "quick smoke test with N nodes (0 = full sweep)") + os.Args = normalizeOptionalPathArgs(os.Args) + flag.Parse() + if flag.NArg() > 0 { + log.Fatalf("unexpected positional argument(s): %v", flag.Args()) + } + + var sweepExplicit, benchmarksExplicit, transferExplicit bool + flag.Visit(func(f *flag.Flag) { + switch f.Name { + case "sweep": + sweepExplicit = true + case "benchmarks": + benchmarksExplicit = true + case "transfer": + transferExplicit = true + } + }) + + // -collect only reconnects to a detached run on the driver, -plot and + // -export-csv only read an existing output directory, and a local + // -explain-check talks only to the LLM, so none needs host selection. A + // driver-routed -explain-check still needs a host (to resolve the driver), + // which resolveDriverHost reports if it is missing. + collecting := cfg.collect != "" || cfg.collectNow != "" + if cfg.collect != "" && cfg.collectNow != "" { + log.Fatal("only one of -collect and -collect-now may be passed") + } + if rawHosts == "" && !collecting && !cfg.list && cfg.plotDir == "" && cfg.exportCSVDir == "" && !cfg.explainCheck { + log.Fatal("-hosts is required; use 'bb[1-30]' for range expansion or 'bb*' to match SSH config entries") + } + if cfg.remoteDir == "" || !filepath.IsAbs(cfg.remoteDir) || filepath.Clean(cfg.remoteDir) == "/" { + log.Fatal("-remote-dir must be an absolute path other than /") + } + if cfg.detach && cfg.driver == "" { + log.Fatal("-detach requires -driver ") + } + if cfg.detach && (collecting || cfg.list) { + log.Fatal("-detach cannot be combined with collection or listing") + } + // Fail on the laptop before running an entire sweep if -explain is + // misconfigured. The key check runs here, not just at triage time, so a + // driven sweep aborts before shipping anything when the laptop lacks the key + // it would forward to the driver. + if cfg.explain || cfg.explainCheck { + if cfg.explainModel == "" { + log.Fatal("-explain/-explain-check requires -explain-model (e.g. -explain-model llama3.3)") + } + if key := providerKeyEnv(cfg.explainProvider); os.Getenv(key) == "" { + log.Fatalf("-explain/-explain-check requires the %s environment variable to be set", key) + } + } + if cfg.pgo { + cfg.collectProfiles = true + } + if err := validateStreamModes(cfg.sweep.streamModes, cfg.binaryPath); err != nil { + log.Fatalf("-stream-mode: %v", err) + } + + if testN > 0 { + cfg.sweep.numNodes = []int{testN} + cfg.sweep.workers = []int{1} + cfg.sweep.payloads = []int{0} + if !benchmarksExplicit { + cfg.sweep.benchmarks = []string{"SymmetricQuorumCall"} + } + cfg.duration = 5 * time.Second + cfg.sweepLabel = "test" + sweepExplicit = true + } + + if cfg.collect == latestRunSentinel || cfg.collectNow == latestRunSentinel || (cfg.list && cfg.driver == "") { + state, err := readLastRunState(cfg.rootDir) + if err != nil { + log.Fatal(err) + } + if cfg.driver == "" { + cfg.driver = state.Driver + } + if cfg.collect == latestRunSentinel { + cfg.collect = state.RemoteWorkDir + } + if cfg.collectNow == latestRunSentinel { + cfg.collectNow = state.RemoteWorkDir + } + cfg.outDir = state.LocalRunDir + if cfg.sshConfig == "" { + cfg.sshConfig = state.SSHConfig + } + if !transferExplicit && state.TransferMode != "" { + cfg.transferMode = state.TransferMode + } + } + if collecting && cfg.driver == "" { + log.Fatal("an explicit collection path requires -driver ; omit the path to use the latest run") + } + + now := time.Now() + if cfg.outDir == "" { + collectPath := cfg.collect + if collectPath == "" { + collectPath = cfg.collectNow + } + cfg.outDir = resolveOutputDir(cfg.rootDir, now, cfg.sweepLabel, sweepExplicit, collectPath) + } + readOnlyMode := cfg.check || cfg.list || cfg.explainCheck || cfg.plotDir != "" || cfg.exportCSVDir != "" + if collecting { + if err := os.MkdirAll(cfg.outDir, 0o755); err != nil { + log.Fatalf("output directory: %v", err) + } + } else if !readOnlyMode { + if err := prepareOutputDir(cfg.outDir); err != nil { + log.Fatalf("output directory: %v", err) + } + } else if err := os.MkdirAll(cfg.rootDir, 0o755); err != nil && cfg.list { + log.Fatalf("output directory: %v", err) + } + + if rawHosts == "" { + // Collection and listing need no benchmark hosts. + return cfg, nil + } + hosts, err := iago.ParseHosts(rawHosts, cfg.sshConfig) + if err != nil { + log.Fatalf("invalid -hosts: %v", err) + } + if len(hosts) == 0 { + log.Fatalf("-hosts %q matched no hosts", rawHosts) + } + return cfg, hosts +} + +func countRuns(sc sweepConfig) int { + streamModes := sc.streamModes + if len(streamModes) == 0 { + streamModes = []string{"dual"} + } + return max(sc.reps, 1) * len(sc.numNodes) * len(sc.workers) * len(sc.payloads) * + len(sc.rates) * len(bufferValues(sc.sendBuffers)) * len(bufferValues(sc.recvBuffers)) * + len(sc.benchmarks) * len(streamModes) +} + +// validateStreamModes checks the swept stream modes. The baseline mode runs a +// prebuilt binary from before stream modes existed, so it requires -binary and +// cannot be mixed with dual or dedup in the same invocation, since one sweep +// deploys exactly one benchmark binary. +func validateStreamModes(modes []string, binaryPath string) error { + if len(modes) == 0 { + return nil + } + for _, mode := range modes { + switch mode { + case "dual", "dedup", "baseline": + default: + return fmt.Errorf("invalid %q (want: dual, dedup, or baseline)", mode) + } + } + if slices.Contains(modes, "baseline") { + if len(modes) != 1 { + return fmt.Errorf("baseline cannot be mixed with other modes; run it as a separate sweep") + } + if binaryPath == "" { + return fmt.Errorf("baseline requires -binary with a prebuilt benchmark binary") + } + } + return nil +} diff --git a/benchkit/cmd/sweep/manifest.go b/benchkit/cmd/sweep/manifest.go new file mode 100644 index 00000000..3528eb33 --- /dev/null +++ b/benchkit/cmd/sweep/manifest.go @@ -0,0 +1,292 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "runtime/debug" + "slices" + "strings" + "time" +) + +// manifestSuffix is the filename suffix of per-run manifests, appended to the +// run base. The report generator discovers runs by this suffix. +const manifestSuffix = ".manifest.json" + +const ( + runStatusStarted = "started" + runStatusSucceeded = "succeeded" + runStatusFailed = "failed" + + // runStatusDegraded marks a run that completed with all result files + // collected but where at least one node's throughput fell below the + // -degraded-below fraction of the run median (see degraded.go). Its data + // is intact and flows into the per-node plot data for diagnosis, but the + // aggregate is contaminated by the slow node, so headline plots treat it + // like a failure. + runStatusDegraded = "degraded" +) + +// Failure phases recorded in a failed run's manifest. They let a reader tell, +// without scanning sweep.log, whether a failed run produced any usable results: +// +// - setup: the run failed before any node wrote a result file (a port +// conflict, or a launch/AwaitReady failure that left zero result files). +// - measurement: nodes ran but some failed mid-benchmark, so only a subset of +// result files were collected. +// - collection: all nodes finished but the result files could not be +// downloaded. +const ( + failurePhaseSetup = "setup" + failurePhaseMeasurement = "measurement" + failurePhaseCollection = "collection" +) + +// runManifest describes one sweep run: how it was configured and which +// per-node result files it is expected to produce. sweep writes it as +// .manifest.json into the output directory before launching the nodes, +// so a results directory is self-describing even when a run fails, and +// the report generator can group per-node files without parsing +// filenames. +type runManifest struct { + runSpec + Label string `json:"label"` // sweep label prefix + Duration string `json:"duration"` // measurement duration + Trim string `json:"trim,omitempty"` // summary trim offset; consumers apply the same trim + Timestamp string `json:"timestamp"` // RFC 3339 launch time + Completed string `json:"completed,omitempty"` // RFC 3339 completion time + Status string `json:"status"` // started, succeeded, or failed + Error string `json:"error,omitempty"` // failure summary when status is failed + + FailurePhase string `json:"failure_phase,omitempty"` // setup, measurement, or collection + CollectedFiles int `json:"collected_files,omitempty"` // result files present at completion + MissingFiles []string `json:"missing_files,omitempty"` // expected result files that are absent + + DegradedNodes []degradedNode `json:"degraded_nodes,omitempty"` // nodes below -degraded-below of the run median + + // TCPStats holds per-host TCP counter deltas over the run (host alias → + // counter → increase), recorded for every run as loss forensics; see + // tcpstats.go. A host with no advanced counters is omitted. + TCPStats map[string]map[string]uint64 `json:"tcp_stats,omitempty"` + + Diagnosis string `json:"diagnosis,omitempty"` // LLM triage verdict from sweep -explain + + GitSHA string `json:"git_sha,omitempty"` // repository HEAD, best effort + Binary string `json:"binary,omitempty"` // deployed binary path + Hosts []string `json:"hosts"` // host:port per node + Files []string `json:"files"` // per-node result file basenames + NodeMap []nodeMapEntry `json:"node_map,omitempty"` // per-node alias, peer address, and Gorums ID +} + +type nodeMapEntry struct { + ID uint32 `json:"id"` // Gorums node ID after peer-address sorting + Host string `json:"host"` // SSH-alias host:port used for artifacts + PeerAddress string `json:"peer_address"` // advertised benchmark peer address + File string `json:"file"` // expected result file basename +} + +// trimString renders the sweep's -trim for the manifest: the duration string +// when set, empty (omitted from the JSON) when zero. +func trimString(trim time.Duration) string { + if trim <= 0 { + return "" + } + return trim.String() +} + +// writeManifest writes the manifest for one run to /.manifest.json. +// Failures are logged, not fatal: the manifest is a convenience for consumers, +// and the run itself proceeds without it. +func writeManifest(outdir, base string, spec runSpec, nodes []nodeAssignment, cfg *config, gitSHA, binary string) { + if spec.StreamMode == "" { + spec.StreamMode = "dual" + } + m := runManifest{ + runSpec: spec, + Label: cfg.sweepLabel, + Duration: cfg.duration.String(), + Trim: trimString(cfg.trim), + Timestamp: time.Now().Format(time.RFC3339), + Status: runStatusStarted, + GitSHA: gitSHA, + Binary: binary, + } + ids := gorumsNodeIDs(nodes) + for _, n := range nodes { + hostAddr := n.hostAddr() + peerAddr := n.peerAddr() + file := resultFilename(base, n, resultExt) + m.Hosts = append(m.Hosts, hostAddr) + m.Files = append(m.Files, file) + m.NodeMap = append(m.NodeMap, nodeMapEntry{ + ID: ids[peerAddr], + Host: hostAddr, + PeerAddress: peerAddr, + File: file, + }) + } + data, err := json.MarshalIndent(&m, "", " ") + if err == nil { + err = os.WriteFile(manifestPath(outdir, base), append(data, '\n'), 0o644) + } + if err != nil { + log.Printf(" warning: manifest: %v", err) + } +} + +func manifestPath(outdir, base string) string { + return filepath.Join(outdir, base+manifestSuffix) +} + +// runOutcome bundles the post-run fields recorded in a manifest by +// updateManifestOutcome. status is required; the remaining fields describe a +// failure (failurePhase, err) and how many of the expected result files were +// collected (collectedFiles, missingFiles), recorded on both success and +// failure so a manifest reader can confirm coverage without scanning the +// output directory. +type runOutcome struct { + status string // runStatusSucceeded, runStatusDegraded, or runStatusFailed + err error // failure cause; nil on success + failurePhase string // setup, measurement, or collection; empty on success + collectedFiles int // result files present in the output directory + missingFiles []string // expected result file basenames that are absent + degraded []degradedNode // nodes below the degraded threshold; set iff status is degraded + tcpStats map[string]map[string]uint64 // per-host TCP counter deltas over the run +} + +// updateManifestOutcome records a run's final outcome in its manifest. It reads +// the started manifest, sets the completion time, status, error, failure phase, +// and result-file counts, and rewrites the file. +func updateManifestOutcome(outdir, base string, o runOutcome) error { + return updateManifest(outdir, base, func(m *runManifest) { + m.Status = o.status + m.Completed = time.Now().Format(time.RFC3339) + if o.err != nil { + m.Error = oneLine(o.err.Error()) + } else { + m.Error = "" + } + m.FailurePhase = o.failurePhase + m.CollectedFiles = o.collectedFiles + m.MissingFiles = o.missingFiles + m.DegradedNodes = o.degraded + m.TCPStats = o.tcpStats + }) +} + +// updateManifestDiagnosis records an LLM triage verdict in a run's manifest. It +// reads the manifest, sets the diagnosis field, and rewrites the file, leaving +// every other field intact. Re-running sweep -explain overwrites the previous +// verdict rather than appending, so the manifest holds only the latest one. +func updateManifestDiagnosis(outdir, base, diagnosis string) error { + return updateManifest(outdir, base, func(m *runManifest) { + m.Diagnosis = diagnosis + }) +} + +func updateManifest(outdir, base string, update func(*runManifest)) error { + path := manifestPath(outdir, base) + data, err := os.ReadFile(path) + if err != nil { + return err + } + var m runManifest + if err := json.Unmarshal(data, &m); err != nil { + return err + } + update(&m) + data, err = json.MarshalIndent(&m, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o644) +} + +// manifestStatus reads a manifest file and returns its status field, or "" if +// the file cannot be read or parsed. Used by the driver launcher to find failed +// runs among the downloaded manifests. +func manifestStatus(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var m runManifest + if err := json.Unmarshal(data, &m); err != nil { + return "" + } + return m.Status +} + +// countResultFiles reports how many of a run's expected per-node result files +// are present in outdir, and the basenames of those that are absent. It drives +// failure-phase classification (zero present after a launch failure means the +// run never reached measurement) and records collection coverage on success. +func countResultFiles(outdir, base string, nodes []nodeAssignment) (collected int, missing []string) { + for _, n := range nodes { + name := resultFilename(base, n, resultExt) + if _, err := os.Stat(filepath.Join(outdir, name)); err == nil { + collected++ + } else { + missing = append(missing, name) + } + } + return collected, missing +} + +func gorumsNodeIDs(nodes []nodeAssignment) map[string]uint32 { + peers := make([]string, len(nodes)) + for i, n := range nodes { + peers[i] = n.peerAddr() + } + slices.Sort(peers) + ids := make(map[string]uint32, len(peers)) + for i, peer := range peers { + ids[peer] = uint32(i + 1) + } + return ids +} + +// gitHeadSHA returns the repository HEAD commit hash, or "" when unavailable +// (e.g. a sweep run outside a git checkout). +func gitHeadSHA() string { + out, err := exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// warnIfStaleBinary warns when the running sweep binary was built from a +// different commit than the repository HEAD. Such a binary silently runs +// outdated code (e.g. a fix committed after the binary was last built), which +// is easy to miss because the sweep otherwise proceeds normally. +func warnIfStaleBinary(headSHA string) { + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + var rev string + if i := slices.IndexFunc(info.Settings, func(s debug.BuildSetting) bool { + return s.Key == "vcs.revision" + }); i >= 0 { + rev = info.Settings[i].Value + } + if msg := staleBinaryWarning(rev, headSHA); msg != "" { + log.Printf("warning: %s", msg) + } +} + +// staleBinaryWarning returns a warning message when binaryRev (the commit the +// binary was built from) differs from headSHA (the repository HEAD), and "" +// when they match or either is unknown. +func staleBinaryWarning(binaryRev, headSHA string) string { + if binaryRev == "" || headSHA == "" || binaryRev == headSHA { + return "" + } + return fmt.Sprintf("sweep binary built from commit %.12s but repository is at %.12s — rebuild with %q", + binaryRev, headSHA, rebuildSweepCommand) +} diff --git a/benchkit/cmd/sweep/manifest_test.go b/benchkit/cmd/sweep/manifest_test.go new file mode 100644 index 00000000..205c4ef1 --- /dev/null +++ b/benchkit/cmd/sweep/manifest_test.go @@ -0,0 +1,303 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/relab/gorums/benchkit" +) + +// TestWriteManifest verifies that writeManifest records the run configuration +// and the expected per-node result files under .manifest.json. +func TestWriteManifest(t *testing.T) { + dir := t.TempDir() + cfg := &config{sweepLabel: "nscale", duration: 10 * time.Second, trim: time.Second} + p := runSpec{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Symmetric", Nodes: 2, Workers: 4, Payload: 16, + Rate: 1000, StreamMode: "dedup", + }, + Rep: 2, + } + nodes := []nodeAssignment{ + {host: "bb1", peerHost: "152.94.162.21", port: 9000}, + {host: "bb2", peerHost: "152.94.162.11", port: 9000}, + } + const base = "nscale_Symmetric_N2_W4_P16_R1000_r2" + + writeManifest(dir, base, p, nodes, cfg, "abc123", "/tmp/bench") + + data, err := os.ReadFile(filepath.Join(dir, base+manifestSuffix)) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var m runManifest + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal manifest: %v", err) + } + + want := runManifest{ + runSpec: runSpec{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Symmetric", Nodes: 2, Workers: 4, Payload: 16, + Rate: 1000, StreamMode: "dedup", + }, + Rep: 2, + }, + Label: "nscale", + Duration: "10s", + Trim: "1s", + Timestamp: m.Timestamp, // checked separately below + Status: runStatusStarted, + GitSHA: "abc123", + Binary: "/tmp/bench", + Hosts: []string{"bb1:9000", "bb2:9000"}, + Files: []string{ + base + "_bb1_9000.binpb", + base + "_bb2_9000.binpb", + }, + NodeMap: []nodeMapEntry{ + { + ID: 2, + Host: "bb1:9000", + PeerAddress: "152.94.162.21:9000", + File: base + "_bb1_9000.binpb", + }, + { + ID: 1, + Host: "bb2:9000", + PeerAddress: "152.94.162.11:9000", + File: base + "_bb2_9000.binpb", + }, + }, + } + if m.Label != want.Label || m.Benchmark != want.Benchmark || + m.Nodes != want.Nodes || m.Workers != want.Workers || + m.Payload != want.Payload || m.Rate != want.Rate || m.Rep != want.Rep || + m.StreamMode != want.StreamMode || + m.Duration != want.Duration || m.Trim != want.Trim || + m.Status != want.Status || m.GitSHA != want.GitSHA || m.Binary != want.Binary { + t.Errorf("manifest = %+v, want %+v", m, want) + } + if !slices.Equal(m.Hosts, want.Hosts) { + t.Errorf("hosts = %v, want %v", m.Hosts, want.Hosts) + } + if !slices.Equal(m.Files, want.Files) { + t.Errorf("files = %v, want %v", m.Files, want.Files) + } + if !slices.Equal(m.NodeMap, want.NodeMap) { + t.Errorf("node_map = %+v, want %+v", m.NodeMap, want.NodeMap) + } + if _, err := time.Parse(time.RFC3339, m.Timestamp); err != nil { + t.Errorf("timestamp %q is not RFC 3339: %v", m.Timestamp, err) + } + + var flat map[string]json.RawMessage + if err := json.Unmarshal(data, &flat); err != nil { + t.Fatalf("unmarshal flat manifest: %v", err) + } + for _, key := range []string{ + "benchmark", "nodes", "workers", "payload", "rate", + "send_buffer", "recv_buffer", "stream_mode", "rep", + } { + if _, ok := flat[key]; !ok { + t.Errorf("flat manifest missing %q: %s", key, data) + } + } + if _, nested := flat["dimensions"]; nested { + t.Errorf("manifest unexpectedly nested dimensions: %s", data) + } +} + +func TestOldManifestDefaultsMissingBuffersToZero(t *testing.T) { + const old = `{ + "benchmark":"Q","nodes":3,"workers":1,"payload":0, + "rate":0,"stream_mode":"dual","rep":1 + }` + var m runManifest + if err := json.Unmarshal([]byte(old), &m); err != nil { + t.Fatal(err) + } + if m.SendBuffer != 0 || m.RecvBuffer != 0 { + t.Fatalf("missing buffers decoded as send=%d recv=%d, want zero", m.SendBuffer, m.RecvBuffer) + } +} + +// readManifest reads and unmarshals the manifest for base, failing the test on +// any error. +func readManifest(t *testing.T, dir, base string) runManifest { + t.Helper() + data, err := os.ReadFile(manifestPath(dir, base)) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + var m runManifest + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal manifest: %v", err) + } + return m +} + +// writeResultFile creates an empty result file for one node so countResultFiles +// counts it as collected. +func writeResultFile(t *testing.T, dir, base string, n nodeAssignment) { + t.Helper() + path := filepath.Join(dir, resultFilename(base, n, resultExt)) + if err := os.WriteFile(path, []byte("x"), 0o644); err != nil { + t.Fatalf("write result file: %v", err) + } +} + +// TestUpdateManifestOutcome verifies that updateManifestOutcome records the +// final status, error, failure phase, and result-file coverage for each of the +// failure phases and for a successful run. +func TestUpdateManifestOutcome(t *testing.T) { + cfg := &config{sweepLabel: "nscale", duration: 10 * time.Second} + p := runSpec{Dimensions: benchkit.Dimensions{ + Nodes: 3, Workers: 4, Benchmark: "Symmetric", + }} + nodes := []nodeAssignment{ + {host: "bb1", peerHost: "152.94.162.11", port: 9000}, + {host: "bb2", peerHost: "152.94.162.12", port: 9000}, + {host: "bb3", peerHost: "152.94.162.13", port: 9000}, + } + const base = "nscale_Symmetric_N3_W4_P0" + + t.Run("SetupFailure", func(t *testing.T) { + dir := t.TempDir() + writeManifest(dir, base, p, nodes, cfg, "", "") + runErr := os.ErrDeadlineExceeded + collected, missing := countResultFiles(dir, base, nodes) + if collected != 0 { + t.Fatalf("collected = %d, want 0 (no result files written)", collected) + } + o := runOutcome{ + status: runStatusFailed, err: runErr, failurePhase: failurePhaseSetup, + collectedFiles: collected, missingFiles: missing, + } + if err := updateManifestOutcome(dir, base, o); err != nil { + t.Fatalf("updateManifestOutcome: %v", err) + } + m := readManifest(t, dir, base) + if m.Status != runStatusFailed { + t.Errorf("status = %q, want %q", m.Status, runStatusFailed) + } + if m.FailurePhase != failurePhaseSetup { + t.Errorf("failure_phase = %q, want %q", m.FailurePhase, failurePhaseSetup) + } + if m.CollectedFiles != 0 { + t.Errorf("collected_files = %d, want 0", m.CollectedFiles) + } + if len(m.MissingFiles) != 3 { + t.Errorf("missing_files = %v, want 3 entries", m.MissingFiles) + } + if !strings.Contains(m.Error, runErr.Error()) { + t.Errorf("error = %q, want containing %q", m.Error, runErr.Error()) + } + if _, err := time.Parse(time.RFC3339, m.Completed); err != nil { + t.Errorf("completed %q is not RFC 3339: %v", m.Completed, err) + } + }) + + t.Run("MeasurementFailure", func(t *testing.T) { + dir := t.TempDir() + writeManifest(dir, base, p, nodes, cfg, "", "") + // Two of three nodes wrote a result file; the third failed mid-run. + writeResultFile(t, dir, base, nodes[0]) + writeResultFile(t, dir, base, nodes[1]) + collected, missing := countResultFiles(dir, base, nodes) + if collected != 2 { + t.Fatalf("collected = %d, want 2", collected) + } + o := runOutcome{ + status: runStatusFailed, err: os.ErrDeadlineExceeded, + failurePhase: failurePhaseMeasurement, collectedFiles: collected, missingFiles: missing, + } + if err := updateManifestOutcome(dir, base, o); err != nil { + t.Fatalf("updateManifestOutcome: %v", err) + } + m := readManifest(t, dir, base) + if m.FailurePhase != failurePhaseMeasurement { + t.Errorf("failure_phase = %q, want %q", m.FailurePhase, failurePhaseMeasurement) + } + if m.CollectedFiles != 2 { + t.Errorf("collected_files = %d, want 2", m.CollectedFiles) + } + want := []string{resultFilename(base, nodes[2], resultExt)} + if !slices.Equal(m.MissingFiles, want) { + t.Errorf("missing_files = %v, want %v", m.MissingFiles, want) + } + }) + + t.Run("Success", func(t *testing.T) { + dir := t.TempDir() + writeManifest(dir, base, p, nodes, cfg, "", "") + for _, n := range nodes { + writeResultFile(t, dir, base, n) + } + collected, missing := countResultFiles(dir, base, nodes) + o := runOutcome{status: runStatusSucceeded, collectedFiles: collected, missingFiles: missing} + if err := updateManifestOutcome(dir, base, o); err != nil { + t.Fatalf("updateManifestOutcome: %v", err) + } + m := readManifest(t, dir, base) + if m.Status != runStatusSucceeded { + t.Errorf("status = %q, want %q", m.Status, runStatusSucceeded) + } + if m.Error != "" { + t.Errorf("error after success = %q, want empty", m.Error) + } + if m.FailurePhase != "" { + t.Errorf("failure_phase after success = %q, want empty", m.FailurePhase) + } + if m.CollectedFiles != 3 { + t.Errorf("collected_files = %d, want 3", m.CollectedFiles) + } + if len(m.MissingFiles) != 0 { + t.Errorf("missing_files after success = %v, want none", m.MissingFiles) + } + }) +} + +// TestStaleBinaryWarning verifies that a warning is produced exactly when the +// binary's embedded VCS revision and the repository HEAD are both known and +// differ, and that the message names both commits and the rebuild command. +func TestStaleBinaryWarning(t *testing.T) { + const ( + head = "127919b96af22a9be8c39627fd2909a3388d6aa1" + stale = "07508fbaf77e68be6da451cce5c299360525b881" + ) + tests := []struct { + name string + binaryRev string + headSHA string + wantWarn bool + }{ + {"Match", head, head, false}, + {"Stale", stale, head, true}, + {"UnknownBinaryRevision", "", head, false}, + {"UnknownHead", stale, "", false}, + {"BothUnknown", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := staleBinaryWarning(tt.binaryRev, tt.headSHA) + if got := msg != ""; got != tt.wantWarn { + t.Fatalf("staleBinaryWarning(%q, %q) = %q, want warning: %v", tt.binaryRev, tt.headSHA, msg, tt.wantWarn) + } + if !tt.wantWarn { + return + } + for _, want := range []string{stale[:12], head[:12], rebuildSweepCommand} { + if !strings.Contains(msg, want) { + t.Errorf("warning %q does not contain %q", msg, want) + } + } + }) + } +} diff --git a/benchkit/cmd/sweep/moduleroot.go b/benchkit/cmd/sweep/moduleroot.go new file mode 100644 index 00000000..75d56cb7 --- /dev/null +++ b/benchkit/cmd/sweep/moduleroot.go @@ -0,0 +1,42 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +const benchkitModulePath = "github.com/relab/gorums/benchkit" + +// requireBenchkitModuleRoot verifies that sweep is running from the benchkit +// module root, where its relative build and binary paths resolve. +func requireBenchkitModuleRoot() error { + if isBenchkitModuleRoot(".") { + return nil + } + return errors.New("run sweep from the benchkit module root") +} + +// isBenchkitModuleRoot reports whether dir has benchkit's module directive and +// command layout. +func isBenchkitModuleRoot(dir string) bool { + data, err := os.ReadFile(filepath.Join(dir, "go.mod")) + if err == nil && modulePath(data) == benchkitModulePath { + if info, statErr := os.Stat(filepath.Join(dir, "cmd", "benchmark")); statErr == nil && info.IsDir() { + return true + } + } + return false +} + +// modulePath returns the module directive from a go.mod file. +func modulePath(data []byte) string { + for line := range strings.SplitSeq(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && fields[0] == "module" { + return fields[1] + } + } + return "" +} diff --git a/benchkit/cmd/sweep/moduleroot_test.go b/benchkit/cmd/sweep/moduleroot_test.go new file mode 100644 index 00000000..21822480 --- /dev/null +++ b/benchkit/cmd/sweep/moduleroot_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestModulePath(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + {"module first", "module github.com/relab/gorums/benchkit\n\ngo 1.26.2\n", benchkitModulePath}, + {"leading comment", "// generated fixture\nmodule example.com/test\n", "example.com/test"}, + {"missing", "go 1.26.2\n", ""}, + {"malformed", "module\n", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := modulePath([]byte(tt.data)); got != tt.want { + t.Errorf("modulePath() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIsBenchkitModuleRoot(t *testing.T) { + tests := []struct { + name string + module string + benchmark bool + want bool + }{ + {"benchkit", benchkitModulePath, true, true}, + {"gorums root", "github.com/relab/gorums", true, false}, + {"missing command", benchkitModulePath, false, false}, + {"missing go.mod", "", true, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.module != "" { + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+tt.module+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + if tt.benchmark { + if err := os.MkdirAll(filepath.Join(dir, "cmd", "benchmark"), 0o755); err != nil { + t.Fatal(err) + } + } + if got := isBenchkitModuleRoot(dir); got != tt.want { + t.Errorf("isBenchkitModuleRoot() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/benchkit/cmd/sweep/netcheck.go b/benchkit/cmd/sweep/netcheck.go new file mode 100644 index 00000000..184bd6d6 --- /dev/null +++ b/benchkit/cmd/sweep/netcheck.go @@ -0,0 +1,135 @@ +package main + +import ( + "cmp" + "context" + "fmt" + "log" + "regexp" + "strconv" + "strings" + "time" + + "github.com/relab/iago" +) + +// Preflight network check: before deploying anything, every host pings its +// ring successor and the sweep aborts when any link shows packet loss at or +// above netcheckMaxLossPct. A lossy link does not fail a benchmark run — TCP +// retransmits through it — but it silently destroys the measurement (a node +// behind a ~25% loss link runs at a few percent of its peers' throughput, +// pinned at the TCP minimum RTO). The ring covers every host's link in both +// directions (it pings out once and is pinged once) at the cost of one probe +// per host, all run in parallel, so the check adds only a few seconds. + +const ( + // netcheckPings and netcheckIntervalS size one probe: 20 pings at 100 ms + // spacing take ~2 s and resolve loss to 5% granularity, coarse but ample + // for the ≥5% abort threshold. + netcheckPings = 20 + netcheckIntervalS = "0.1" + // netcheckDeadlineS caps a probe wall-clock (ping -w) so a black-holed + // target cannot stall the check; unanswered pings count as lost. + netcheckDeadlineS = "8" + // netcheckMaxLossPct is the loss percentage at which the sweep aborts. + // Healthy LAN links lose nothing; a link at 5%+ already means constant + // TCP retransmission stalls in a latency benchmark. + netcheckMaxLossPct = 5.0 +) + +// linkLoss records the measured packet loss of one ring probe. +type linkLoss struct { + from string // probing host alias + target string // probed peer address + pct float64 // packet loss percentage +} + +// pingCommand returns the loss probe run on each host: fixed count and +// interval, a hard deadline, quiet output (only the summary line is parsed). +func pingCommand(target string) string { + return fmt.Sprintf("ping -c %d -i %s -w %s -q %s", + netcheckPings, netcheckIntervalS, netcheckDeadlineS, iago.Quote(target)) +} + +// pingLossRE matches the loss percentage in the ping summary line, e.g. +// "50 packets transmitted, 36 received, 28% packet loss, time 10191ms". +var pingLossRE = regexp.MustCompile(`([0-9.]+)% packet loss`) + +// parsePingLoss extracts the packet-loss percentage from ping output. +func parsePingLoss(out string) (float64, error) { + m := pingLossRE.FindStringSubmatch(out) + if m == nil { + return 0, fmt.Errorf("no packet-loss summary in ping output: %q", oneLine(out)) + } + return strconv.ParseFloat(m[1], 64) +} + +// ringTargets assigns every host its ring successor's peer address (the same +// address the benchmark itself dials), so each host's link is probed once in +// each direction. A single host has no link to check. +func ringTargets(hosts []hostAssignment) map[string]string { + if len(hosts) < 2 { + return nil + } + targets := make(map[string]string, len(hosts)) + for i, h := range hosts { + next := hosts[(i+1)%len(hosts)] + targets[h.alias] = cmp.Or(next.peerHost, next.alias) + } + return targets +} + +// netcheckFailure returns an error naming every link at or above the loss +// limit, or nil when all links are below it. Links with minor loss are the +// caller's to log; only limit-or-worse loss aborts the sweep. +func netcheckFailure(losses []linkLoss, limitPct float64) error { + var bad []string + for _, l := range losses { + if l.pct >= limitPct { + bad = append(bad, fmt.Sprintf("%s -> %s: %.0f%% loss", l.from, l.target, l.pct)) + } + } + if len(bad) == 0 { + return nil + } + return fmt.Errorf("network check failed — packet loss at or above %.0f%% (fix the link, exclude the host, or skip with -netcheck=false):\n %s", + limitPct, strings.Join(bad, "\n ")) +} + +// checkNetworkHealth probes every host's link via ringTargets and returns an +// error when any link loses netcheckMaxLossPct or more. Probe failures (ping +// missing, unparseable output) are logged and skipped rather than fatal: the +// check is a tripwire for lossy links, not a connectivity gate — the SSH +// connections already proved the hosts reachable. +func checkNetworkHealth(g iago.Group, hosts []hostAssignment) error { + targets := ringTargets(hosts) + if len(targets) == 0 { + return nil + } + pcts, _ := iago.Collect(withTimeout(g, 60*time.Second), "netcheck", func(ctx context.Context, host iago.Host) (float64, error) { + target := targets[host.Name()] + if target == "" { + return 0, nil + } + // ping exits non-zero when packets were lost; the summary line is + // still printed, so the exit status is ignored and only parse + // failures are reported. + out, _ := iago.Output(ctx, host, pingCommand(target)) + pct, err := parsePingLoss(out) + if err != nil { + log.Printf(" warning: netcheck %s -> %s: %v", host.Name(), target, err) + return 0, nil + } + if pct > 0 { + log.Printf(" netcheck %s -> %s: %.0f%% loss", host.Name(), target, pct) + } + return pct, nil + }) + var losses []linkLoss + for host, pct := range pcts { + if pct > 0 { + losses = append(losses, linkLoss{from: host, target: targets[host], pct: pct}) + } + } + return netcheckFailure(losses, netcheckMaxLossPct) +} diff --git a/benchkit/cmd/sweep/netcheck_test.go b/benchkit/cmd/sweep/netcheck_test.go new file mode 100644 index 00000000..890aeb6b --- /dev/null +++ b/benchkit/cmd/sweep/netcheck_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "math" + "strings" + "testing" +) + +// TestParsePingLoss verifies extraction of the packet-loss percentage from +// Linux ping summary output, including fractional percentages and the error +// on unrecognized output. +func TestParsePingLoss(t *testing.T) { + tests := []struct { + name string + out string + want float64 + wantErr bool + }{ + { + name: "no loss", + out: `--- 152.94.162.19 ping statistics --- +20 packets transmitted, 20 received, 0% packet loss, time 1918ms +rtt min/avg/max/mdev = 0.139/0.193/0.240/0.013 ms`, + want: 0, + }, + { + name: "heavy loss", + out: `--- 152.94.162.26 ping statistics --- +50 packets transmitted, 36 received, 28% packet loss, time 10191ms`, + want: 28, + }, + { + name: "fractional loss", + out: "100 packets transmitted, 99 received, 1.5% packet loss, time 9912ms", + want: 1.5, + }, + { + name: "unrecognized output", + out: "ping: unknown host bb99", + wantErr: true, + }, + { + name: "empty", + out: "", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePingLoss(tt.out) + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + if !tt.wantErr && math.Abs(got-tt.want) > 1e-9 { + t.Errorf("loss = %v, want %v", got, tt.want) + } + }) + } +} + +// TestRingTargets verifies that each host is assigned its ring successor's +// peer address, covering every host's link in both directions across the +// ring, and that a single host has nothing to check. +func TestRingTargets(t *testing.T) { + hosts := []hostAssignment{ + {alias: "bb2"}, + {alias: "bb3", peerHost: "152.94.162.13"}, + {alias: "bb4"}, + } + got := ringTargets(hosts) + want := map[string]string{ + "bb2": "152.94.162.13", // bb3's advertised peer address + "bb3": "bb4", // no peerHost -> alias + "bb4": "bb2", // ring wraps + } + if len(got) != len(want) { + t.Fatalf("targets = %v, want %v", got, want) + } + for from, to := range want { + if got[from] != to { + t.Errorf("target[%q] = %q, want %q", from, got[from], to) + } + } + + if got := ringTargets(hosts[:1]); len(got) != 0 { + t.Errorf("single host targets = %v, want none", got) + } +} + +// TestNetcheckFailure verifies the abort decision: loss at or above the limit +// on any link fails the check with every lossy link named, while loss below +// the limit (or no loss) passes. +func TestNetcheckFailure(t *testing.T) { + losses := []linkLoss{ + {from: "bb2", target: "bb16", pct: 28}, + {from: "bb16", target: "bb17", pct: 24}, + {from: "bb3", target: "bb4", pct: 1}, + } + err := netcheckFailure(losses, 5) + if err == nil { + t.Fatal("expected failure for links at 28% and 24% loss") + } + for _, want := range []string{"bb2", "bb16", "28", "24", "-netcheck=false"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error missing %q\ngot: %v", want, err) + } + } + if strings.Contains(err.Error(), "bb3 -> bb4") { + t.Errorf("sub-limit link should not be listed as a failure\ngot: %v", err) + } + + if err := netcheckFailure(losses[2:], 5); err != nil { + t.Errorf("1%% loss should pass at a 5%% limit, got: %v", err) + } + if err := netcheckFailure(nil, 5); err != nil { + t.Errorf("no loss should pass, got: %v", err) + } +} + +// TestPingCommand verifies the probe command shape: fixed count and interval, +// a deadline so a black-holed target cannot stall the check, quiet output, +// and a shell-quoted target. +func TestPingCommand(t *testing.T) { + cmd := pingCommand("152.94.162.26") + for _, want := range []string{"ping", "-c 20", "-i 0.1", "-w 8", "-q", "'152.94.162.26'"} { + if !strings.Contains(cmd, want) { + t.Errorf("command missing %q\ngot: %s", want, cmd) + } + } +} diff --git a/benchkit/cmd/sweep/offsets.go b/benchkit/cmd/sweep/offsets.go new file mode 100644 index 00000000..58a2870c --- /dev/null +++ b/benchkit/cmd/sweep/offsets.go @@ -0,0 +1,183 @@ +package main + +import ( + "bufio" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" +) + +// offsetLineRE matches a per-run clock-offset diagnostic line, e.g. +// +// [offsets node 15 (10.0.0.1:9000)] peer 2: before=-297µs after=-301µs drift=-3µs +// +// before is the raw skew the correction removes; drift is the residual it +// cannot. Both carry a Go-style duration unit. +// The magnitudes are single-unit Go durations (µs is U+00B5, as time.Duration +// prints). Clock skew is sub-second in practice, but m/h are accepted so a +// pathologically desynced peer is recorded rather than silently dropped. +var offsetLineRE = regexp.MustCompile( + `\[offsets node (\d+) \([^)]*\)\] peer (\d+): ` + + `before=(-?[\d.]+)(ns|us|µs|ms|s|m|h) ` + + `after=-?[\d.]+(?:ns|us|µs|ms|s|m|h) ` + + `drift=(-?[\d.]+)(ns|us|µs|ms|s|m|h)`) + +var unitToUS = map[string]float64{ + "ns": 1e-3, "us": 1, "µs": 1, "ms": 1e3, "s": 1e6, "m": 60e6, "h": 3600e6, +} + +// nodeCountRE extracts the run's node count from a log filename (…_N28_…). +var nodeCountRE = regexp.MustCompile(`_N(\d+)_`) + +// offsetSample is one cross-machine clock observation: the absolute skew and +// residual drift, in microseconds, tagged with the run's node count. +type offsetSample struct { + nodeCount int + offsetUS float64 + driftUS float64 +} + +// collectOffsets parses every *.log under logDir for clock-offset diagnostic +// lines, returning one sample per cross-machine peer observation. Self/loopback +// peers (node == peer) are skipped: they carry no cross-machine skew. +func collectOffsets(logDir string) ([]offsetSample, error) { + logs, err := filepath.Glob(filepath.Join(logDir, "*.log")) + if err != nil { + return nil, err + } + var samples []offsetSample + for _, path := range logs { + nodeCount := 0 + if m := nodeCountRE.FindStringSubmatch(filepath.Base(path)); m != nil { + nodeCount, _ = strconv.Atoi(m[1]) + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + if !strings.Contains(line, "[offsets") { + continue + } + m := offsetLineRE.FindStringSubmatch(line) + if m == nil || m[1] == m[2] { + continue + } + samples = append(samples, offsetSample{ + nodeCount: nodeCount, + offsetUS: absUS(m[3], m[4]), + driftUS: absUS(m[5], m[6]), + }) + } + closeErr := f.Close() + if err := sc.Err(); err != nil { + return nil, err + } + if closeErr != nil { + return nil, closeErr + } + } + return samples, nil +} + +// absUS parses a signed duration magnitude with unit into absolute microseconds. +func absUS(value, unit string) float64 { + v, _ := strconv.ParseFloat(value, 64) + if v < 0 { + v = -v + } + return v * unitToUS[unit] +} + +// offsetCDFRecord is one point of an empirical CDF of a clock metric. +type offsetCDFRecord struct { + metric string // "offset" or "drift" + group string // "all" or "N" + valueUS float64 + cdf float64 +} + +// offsetCDFRows builds empirical CDFs of the absolute offset and drift, overall +// ("all") and per node count ("N"), each sampled at points+1 quantiles. +// Groups with no samples are omitted; the rows are ordered metric, group, +// then ascending value. +func offsetCDFRows(samples []offsetSample, points int) []offsetCDFRecord { + offsets := map[string][]float64{} + drifts := map[string][]float64{} + add := func(group string, s offsetSample) { + offsets[group] = append(offsets[group], s.offsetUS) + drifts[group] = append(drifts[group], s.driftUS) + } + for _, s := range samples { + add("all", s) + if s.nodeCount > 0 { + add("N"+strconv.Itoa(s.nodeCount), s) + } + } + + groups := offsetGroupOrder(samples) + var out []offsetCDFRecord + for _, m := range []struct { + name string + data map[string][]float64 + }{{"offset", offsets}, {"drift", drifts}} { + for _, g := range groups { + out = append(out, cdfPointsFor(m.name, g, m.data[g], points)...) + } + } + return out +} + +// offsetGroupOrder returns "all" followed by each distinct node count present, +// ascending. +func offsetGroupOrder(samples []offsetSample) []string { + seen := map[int]bool{} + for _, s := range samples { + if s.nodeCount > 0 { + seen[s.nodeCount] = true + } + } + groups := []string{"all"} + for _, c := range slices.Sorted(maps.Keys(seen)) { + groups = append(groups, "N"+strconv.Itoa(c)) + } + return groups +} + +// cdfPointsFor samples the empirical CDF of xs at points+1 evenly spaced +// quantiles. +func cdfPointsFor(metric, group string, xs []float64, points int) []offsetCDFRecord { + if len(xs) == 0 { + return nil + } + s := slices.Sorted(slices.Values(xs)) + n := len(s) + out := make([]offsetCDFRecord, 0, points+1) + for i := 0; i <= points; i++ { + q := float64(i) / float64(points) + k := min(int(q*float64(n)), n-1) + out = append(out, offsetCDFRecord{ + metric: metric, + group: group, + valueUS: s[k], + cdf: float64(k+1) / float64(n), + }) + } + return out +} + +// writeOffsetsCSV writes the clock-offset CDF rows. +func writeOffsetsCSV(path string, rows []offsetCDFRecord) error { + return writeCSV(path, + []string{"metric", "group", "value_us", "cdf"}, + rows, func(r offsetCDFRecord) []string { + return []string{r.metric, r.group, formatFloat(r.valueUS), formatFloat(r.cdf)} + }) +} diff --git a/benchkit/cmd/sweep/offsets_test.go b/benchkit/cmd/sweep/offsets_test.go new file mode 100644 index 00000000..03a729b3 --- /dev/null +++ b/benchkit/cmd/sweep/offsets_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCollectOffsets(t *testing.T) { + dir := t.TempDir() + log := "" + + "noise line\n" + + "[offsets node 15 (10.0.0.1:9000)] peer 2: before=-297µs after=-301µs drift=-3µs\n" + + "[offsets node 15 (10.0.0.1:9000)] peer 15: before=0µs after=0µs drift=0µs\n" + // self, skipped + "[offsets node 15 (10.0.0.1:9000)] peer 3: before=1ms after=900µs drift=100µs\n" + if err := os.WriteFile(filepath.Join(dir, "run_Q_N15_W8.log"), []byte(log), 0o644); err != nil { + t.Fatal(err) + } + samples, err := collectOffsets(dir) + if err != nil { + t.Fatal(err) + } + if len(samples) != 2 { + t.Fatalf("samples = %d, want 2 (self peer skipped)", len(samples)) + } + // before=-297µs -> |offset| 297; before=1ms -> 1000µs; drift 100µs. + if samples[0].offsetUS != 297 || samples[0].nodeCount != 15 { + t.Errorf("sample0 = %+v, want offset 297 nodes 15", samples[0]) + } + if samples[1].offsetUS != 1000 || samples[1].driftUS != 100 { + t.Errorf("sample1 = %+v, want offset 1000 drift 100", samples[1]) + } +} + +func TestOffsetCDFRows(t *testing.T) { + samples := []offsetSample{ + {nodeCount: 9, offsetUS: 10, driftUS: 1}, + {nodeCount: 9, offsetUS: 20, driftUS: 2}, + {nodeCount: 15, offsetUS: 100, driftUS: 5}, + } + rows := offsetCDFRows(samples, 10) + // Groups: all, N9, N15 for each of offset+drift, each with 11 points. + if len(rows) != 2*3*11 { + t.Fatalf("rows = %d, want %d", len(rows), 2*3*11) + } + // Every CDF ends at 1.0 and starts non-empty; check the "all" offset tail. + var last offsetCDFRecord + for _, r := range rows { + if r.metric == "offset" && r.group == "all" { + last = r + } + } + if last.cdf != 1.0 || last.valueUS != 100 { + t.Errorf("all offset tail = %+v, want cdf 1.0 value 100", last) + } + + path := filepath.Join(t.TempDir(), "offsets.csv") + if err := writeOffsetsCSV(path, rows); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} + +func TestRunStatusRows(t *testing.T) { + dir := t.TempDir() + n1 := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, "r_Q_N3_r1", runStatusSucceeded, 1, "", []string{resultFilename("r_Q_N3_r1", n1, resultExt)}) + writePlotManifest(t, dir, "r_Q_N3_r2", runStatusDegraded, 2, "", []string{resultFilename("r_Q_N3_r2", n1, resultExt)}) + writePlotManifest(t, dir, "r_Q_N3_r3", runStatusFailed, 3, "", []string{resultFilename("r_Q_N3_r3", n1, resultExt)}) + + rows, err := runStatusRows(dir) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1 node count", len(rows)) + } + r := rows[0] + if r.total != 3 || r.succeeded != 1 || r.degraded != 1 || r.failed != 1 || r.completed != 2 { + t.Errorf("row = %+v, want total3 succ1 deg1 fail1 completed2", r) + } + if !anyDegradedOrFailed(rows) { + t.Error("anyDegradedOrFailed = false, want true") + } + + path := filepath.Join(dir, "run_status.csv") + if err := writeRunStatusCSV(path, rows); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} diff --git a/benchkit/cmd/sweep/outdir.go b/benchkit/cmd/sweep/outdir.go new file mode 100644 index 00000000..ed4530b7 --- /dev/null +++ b/benchkit/cmd/sweep/outdir.go @@ -0,0 +1,143 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "time" +) + +const defaultOutRoot = "out" + +// resolveOutputDir returns the concrete run directory under rootDir. +// Explicit sweep labels name the directory; unlabeled runs keep the timestamped layout. +// Reconnecting to a detached driver run reuses the run directory encoded in -collect. +func resolveOutputDir(rootDir string, now time.Time, sweepLabel string, sweepExplicit bool, collectPath string) string { + if collectPath != "" { + return filepath.Join(rootDir, runDirNameFromCollectPath(collectPath)) + } + if sweepExplicit && sweepLabel != "" { + return filepath.Join(rootDir, sweepLabel) + } + return filepath.Join(rootDir, now.Format("20060102_150405")) +} + +// runDirNameFromCollectPath derives the run directory name from the driver's +// detached work directory. The launcher encodes the actual run directory name +// before the unique timestamp suffix, so reconnecting can recover it. +func runDirNameFromCollectPath(collectPath string) string { + base := filepath.Base(filepath.Clean(collectPath)) + const prefix = "sweep-driver-" + if !strings.HasPrefix(base, prefix) { + return base + } + base = strings.TrimPrefix(base, prefix) + if i := strings.LastIndex(base, "-"); i >= 0 { + return base[:i] + } + return base +} + +// displayPath returns path relative to the current working directory, so log +// output shows a short, copy-pasteable path (e.g. "out/label" or +// "../data/label") instead of the absolute path that cfg.outDir carries +// internally. It falls back to path unchanged when the working directory is +// unknown or no relative form exists. +func displayPath(path string) string { + cwd, err := os.Getwd() + if err != nil { + return path + } + rel, err := filepath.Rel(cwd, path) + if err != nil { + return path + } + return rel +} + +// prepareOutputDir creates path and, when it already exists, moves the existing +// directory aside first. +func prepareOutputDir(path string) error { + if moved, err := rotateExistingOutputDir(path); err != nil { + return err + } else if moved != "" { + log.Printf("existing output directory moved aside: %s -> %s", path, moved) + } + return os.MkdirAll(path, 0o755) +} + +// rotateExistingOutputDir renames an existing sweep output directory to a +// timestamp-suffixed sibling and returns the new path. If path does not exist, +// it returns an empty string. +func rotateExistingOutputDir(path string) (string, error) { + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return "", nil + } + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("%s exists and is not a directory", path) + } + + stamp, ok := sweepDirTimestamp(path) + if !ok { + stamp = info.ModTime() + } + base := path + "-" + stamp.Format("20060102_150405") + moved := base + for i := 1; ; i++ { + if _, err := os.Stat(moved); errors.Is(err, fs.ErrNotExist) { + break + } + moved = fmt.Sprintf("%s-%d", base, i) + } + if err := os.Rename(path, moved); err != nil { + return "", err + } + return moved, nil +} + +// sweepDirTimestamp returns the earliest manifest timestamp in dir, or the +// directory mtime when no usable manifest exists. +func sweepDirTimestamp(dir string) (time.Time, bool) { + matches, err := filepath.Glob(filepath.Join(dir, "*"+manifestSuffix)) + if err == nil { + var ( + best time.Time + found bool + ) + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + continue + } + var m runManifest + if err := json.Unmarshal(data, &m); err != nil || m.Timestamp == "" { + continue + } + ts, err := time.Parse(time.RFC3339, m.Timestamp) + if err != nil { + continue + } + if !found || ts.Before(best) { + best = ts + found = true + } + } + if found { + return best, true + } + } + info, err := os.Stat(dir) + if err != nil { + return time.Time{}, false + } + return info.ModTime(), true +} diff --git a/benchkit/cmd/sweep/outdir_test.go b/benchkit/cmd/sweep/outdir_test.go new file mode 100644 index 00000000..de2163f5 --- /dev/null +++ b/benchkit/cmd/sweep/outdir_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestResolveOutputDir(t *testing.T) { + now := time.Date(2026, 6, 27, 13, 2, 18, 0, time.UTC) + tests := []struct { + name string + rootDir string + sweepLabel string + sweepExplicit bool + collectPath string + want string + }{ + { + name: "explicit sweep label", + rootDir: "results", + sweepLabel: "e1", + sweepExplicit: true, + want: filepath.Join("results", "e1"), + }, + { + name: "timestamped run", + rootDir: "results", + sweepLabel: "run", + want: filepath.Join("results", "20260627_130218"), + }, + { + name: "reconnect path", + rootDir: "results", + collectPath: "/tmp/sweep-driver-e3-tlcurve-20260627_130218", + want: filepath.Join("results", "e3-tlcurve"), + }, + { + name: "reconnect path with timestamp label", + rootDir: "results", + collectPath: "/tmp/sweep-driver-20260627_130218-20260627_130219", + want: filepath.Join("results", "20260627_130218"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveOutputDir(tt.rootDir, now, tt.sweepLabel, tt.sweepExplicit, tt.collectPath) + if got != tt.want { + t.Fatalf("resolveOutputDir() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestRotateExistingOutputDirUsesManifestTimestamp(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "e1") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + older := time.Date(2026, 6, 27, 12, 3, 4, 0, time.UTC) + newer := older.Add(5 * time.Minute) + writeTestManifest(t, path, "first", newer) + writeTestManifest(t, path, "second", older) + + rotated, err := rotateExistingOutputDir(path) + if err != nil { + t.Fatalf("rotateExistingOutputDir: %v", err) + } + want := filepath.Join(root, "e1-"+older.Format("20060102_150405")) + if rotated != want { + t.Fatalf("rotated = %q, want %q", rotated, want) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("original path still exists: %v", err) + } + if _, err := os.Stat(want); err != nil { + t.Fatalf("rotated path missing: %v", err) + } +} + +func TestRotateExistingOutputDirUsesDirModTimeFallback(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "e2") + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + stamp := time.Date(2026, 6, 27, 12, 34, 56, 0, time.Local) + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatalf("chtimes: %v", err) + } + + rotated, err := rotateExistingOutputDir(path) + if err != nil { + t.Fatalf("rotateExistingOutputDir: %v", err) + } + want := filepath.Join(root, "e2-"+stamp.Format("20060102_150405")) + if rotated != want { + t.Fatalf("rotated = %q, want %q", rotated, want) + } +} + +func writeTestManifest(t *testing.T, dir, base string, ts time.Time) { + t.Helper() + m := runManifest{Timestamp: ts.Format(time.RFC3339)} + data, err := json.MarshalIndent(&m, "", " ") + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + path := filepath.Join(dir, base+manifestSuffix) + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } +} + +func TestDisplayPath(t *testing.T) { + cwd := t.TempDir() + t.Chdir(cwd) + + tests := []struct { + name string + path string + want string + }{ + { + name: "under cwd", + path: filepath.Join(cwd, "out", "dedup-recheck"), + want: filepath.Join("out", "dedup-recheck"), + }, + { + name: "cwd itself", + path: cwd, + want: ".", + }, + { + name: "outside cwd", + path: filepath.Join(filepath.Dir(cwd), "elsewhere"), + want: filepath.Join("..", "elsewhere"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := displayPath(tt.path); got != tt.want { + t.Fatalf("displayPath(%q) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} diff --git a/benchkit/cmd/sweep/plotdata.go b/benchkit/cmd/sweep/plotdata.go new file mode 100644 index 00000000..b7e1e46a --- /dev/null +++ b/benchkit/cmd/sweep/plotdata.go @@ -0,0 +1,938 @@ +package main + +import ( + "cmp" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log" + "maps" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/relab/gorums/benchkit" + "google.golang.org/protobuf/proto" +) + +const ( + plotDataDir = "plotdata" + plotDataFile = "plotdata.binpb" + plotEventsFile = "events.binpb" + compactTransferDir = "compact-transfer" + compactMarker = "compact.collected" + cdfPoints = 200 +) + +type plotRunRecord struct { + benchkit.Dimensions + base string + label string + status string // succeeded or degraded; consumers exclude degraded from aggregates + rep int + throughput float64 + totalOps uint64 + failedOps uint64 + allocsPerOp float64 + memPerOp float64 + nodesSeen int + // Latency summaries are pointers so an absent distribution (a run that + // recorded no latency samples) is nil rather than a spurious zero. A zero + // would be indistinguishable from a real measurement and would pull down + // rep-averaged means; nil lets aggregation skip the run for these metrics. + meanUS *float64 + p50US *float64 + p95US *float64 + p99US *float64 + samples *uint64 +} + +type plotNodeCDFRecord struct { + benchkit.Dimensions + base string + label string + status string // succeeded or degraded; degraded rows drive node-health diagnosis + rep int + node string + throughput float64 + meanUS float64 + p50US float64 + p95US float64 + p99US float64 + samples uint64 + prob float64 + cdfUS float64 +} + +type plotNodeEntry struct { + benchkit.Dimensions + node string + throughput float64 + totalOps uint64 + failedOps uint64 + allocs float64 + mem float64 + latency *benchkit.LatencyDist + measurementMode benchkit.MeasurementMode +} + +// writeCompactPlotData reduces the binary result files into the compact, +// normalized plotdata.binpb the report generator can render without downloading +// every raw result file, plus the events.binpb beside it holding every run's +// time-series event streams. Failed runs are intentionally excluded from the +// plot data, which has no row for a run without an aggregate; their event +// streams are exported like any other run's, and their .binpb files are copied +// into the compact transfer directory for local diagnosis. +func writeCompactPlotData(outdir string) error { + runs, nodes, events, err := collectPlotData(outdir) + if err != nil { + return err + } + dir := filepath.Join(outdir, plotDataDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + data, err := proto.Marshal(buildPlotData(runs, nodes)) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, plotDataFile), data, 0o644); err != nil { + return err + } + return writePlotEvents(dir, events) +} + +// writePlotEvents writes the sweep's event streams next to plotdata.binpb, +// removing a stale file when this collection found no events at all, so a +// re-export never leaves an earlier run's streams behind. +func writePlotEvents(dir string, events *benchkit.PlotEvents) error { + path := filepath.Join(dir, plotEventsFile) + if len(events.GetRuns()) == 0 { + if err := os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil + } + data, err := proto.Marshal(events) + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +// readPlotEvents reads the event streams a prior collection wrote next to +// plotdata.binpb. It returns nil without an error when the file is absent: a +// directory collected before the streams were exported, or a sweep run with +// interval reporting off, has none. +func readPlotEvents(dir string) (*benchkit.PlotEvents, error) { + data, err := os.ReadFile(filepath.Join(dir, plotDataDir, plotEventsFile)) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + events := &benchkit.PlotEvents{} + if err := proto.Unmarshal(data, events); err != nil { + return nil, err + } + return events, nil +} + +// exportPlotCSV regenerates plotdata/runs.csv and plotdata/nodes.csv from a +// collected directory's plotdata.binpb, for humans and agents to grep. This +// is a local, on-demand secondary form: the primary storage and transfer +// format is the compact plotdata.binpb, not these CSVs. +func exportPlotCSV(dir string) error { + pd, err := readPlotData(dir) + if err != nil { + return err + } + runs, cdf := plotRecordsFromMessage(pd) + plotdataDir := filepath.Join(dir, plotDataDir) + if err := writePlotRunsCSV(filepath.Join(plotdataDir, "runs.csv"), runs); err != nil { + return err + } + return writePlotNodesCSV(filepath.Join(plotdataDir, "nodes.csv"), cdf) +} + +// readPlotData reads and decodes the plotdata.binpb a prior collection wrote +// into the sweep output directory dir. +func readPlotData(dir string) (*benchkit.PlotData, error) { + data, err := os.ReadFile(filepath.Join(dir, plotDataDir, plotDataFile)) + if err != nil { + return nil, err + } + pd := &benchkit.PlotData{} + if err := proto.Unmarshal(data, pd); err != nil { + return nil, err + } + return pd, nil +} + +// buildPlotData normalizes flat run and per-node CDF records into the nested +// PlotData message used for on-disk and cross-network storage: run identity +// is stored once per run and node identity once per node, rather than being +// repeated on every CDF point. +func buildPlotData(runs []plotRunRecord, cdf []plotNodeCDFRecord) *benchkit.PlotData { + var out []*benchkit.PlotRun + runIdx := make(map[string]int) + type benchKey struct{ base, benchmark string } + benchIdx := make(map[benchKey]int) + + for _, r := range runs { + i, ok := runIdx[r.base] + if !ok { + i = len(out) + runIdx[r.base] = i + out = append(out, benchkit.PlotRun_builder{ + Base: r.base, + Label: r.label, + Status: r.status, + Rep: int32(r.rep), + }.Build()) + } + run := out[i] + benchIdx[benchKey{r.base, r.Benchmark}] = len(run.GetBenchmarks()) + run.SetBenchmarks(append(run.GetBenchmarks(), benchkit.PlotBenchmark_builder{ + Config: benchkit.NewRunConfig(r.Dimensions), + Throughput: r.throughput, + TotalOps: r.totalOps, + FailedOps: r.failedOps, + AllocsPerOp: r.allocsPerOp, + MemPerOp: r.memPerOp, + NodesSeen: int32(r.nodesSeen), + Summary: runLatencySummary(r.meanUS, r.p50US, r.p95US, r.p99US, r.samples), + }.Build())) + } + + type nodeKey struct{ base, benchmark, node string } + var curKey nodeKey + var curNode *benchkit.PlotNode + flushNode := func() { + if curNode == nil { + return + } + i, ok := benchIdx[benchKey{curKey.base, curKey.benchmark}] + if !ok { + return // orphan CDF rows without a matching run row; drop them + } + bench := out[runIdx[curKey.base]].GetBenchmarks()[i] + bench.SetNodes(append(bench.GetNodes(), curNode)) + } + for _, c := range cdf { + key := nodeKey{c.base, c.Benchmark, c.node} + if curNode == nil || key != curKey { + flushNode() + curKey, curNode = key, benchkit.PlotNode_builder{ + Node: c.node, + Throughput: c.throughput, + Summary: benchkit.LatencySummary_builder{ + MeanUs: c.meanUS, P50Us: c.p50US, P95Us: c.p95US, P99Us: c.p99US, Samples: c.samples, + }.Build(), + }.Build() + } + curNode.SetCdfUs(append(curNode.GetCdfUs(), c.cdfUS)) + } + flushNode() + + return benchkit.PlotData_builder{Runs: out}.Build() +} + +// runLatencySummary builds a benchmark-level LatencySummary, returning nil +// when the run recorded no latency samples so an absent distribution stays +// distinguishable from a genuine all-zero measurement. +func runLatencySummary(meanUS, p50US, p95US, p99US *float64, samples *uint64) *benchkit.LatencySummary { + if samples == nil { + return nil + } + return benchkit.LatencySummary_builder{ + MeanUs: *meanUS, P50Us: *p50US, P95Us: *p95US, P99Us: *p99US, Samples: *samples, + }.Build() +} + +// plotRecordsFromMessage flattens a normalized PlotData message back into the +// per-run and per-node-CDF records the report pipeline consumes, undoing the +// grouping buildPlotData performed and re-deriving each CDF point's +// cumulative probability from its position on the fixed grid. +func plotRecordsFromMessage(pd *benchkit.PlotData) ([]plotRunRecord, []plotNodeCDFRecord) { + var runs []plotRunRecord + var cdf []plotNodeCDFRecord + for _, run := range pd.GetRuns() { + for _, bench := range run.GetBenchmarks() { + cfg := bench.GetConfig() + row := plotRunRecord{ + Dimensions: cfg.Dimensions(), + base: run.GetBase(), + label: run.GetLabel(), + status: run.GetStatus(), + rep: int(run.GetRep()), + throughput: bench.GetThroughput(), + totalOps: bench.GetTotalOps(), + failedOps: bench.GetFailedOps(), + allocsPerOp: bench.GetAllocsPerOp(), + memPerOp: bench.GetMemPerOp(), + nodesSeen: int(bench.GetNodesSeen()), + } + if s := bench.GetSummary(); s != nil { + meanUS, p50US, p95US, p99US, samples := s.GetMeanUs(), s.GetP50Us(), s.GetP95Us(), s.GetP99Us(), s.GetSamples() + row.meanUS, row.p50US, row.p95US, row.p99US, row.samples = &meanUS, &p50US, &p95US, &p99US, &samples + } + runs = append(runs, row) + + for _, node := range bench.GetNodes() { + s := node.GetSummary() + cdfUS := node.GetCdfUs() + for i, v := range cdfUS { + cdf = append(cdf, plotNodeCDFRecord{ + Dimensions: cfg.Dimensions(), + base: run.GetBase(), + label: run.GetLabel(), + status: run.GetStatus(), + rep: int(run.GetRep()), + node: node.GetNode(), + throughput: node.GetThroughput(), + meanUS: s.GetMeanUs(), + p50US: s.GetP50Us(), + p95US: s.GetP95Us(), + p99US: s.GetP99Us(), + samples: s.GetSamples(), + prob: cdfProbAt(i, len(cdfUS)), + cdfUS: v, + }) + } + } + } + } + return runs, cdf +} + +// collectPlotData reduces every run in outdir to its plot rows and its event +// streams, in a single pass over the raw per-node result files. Runs whose +// per-node data is not intact contribute event streams only (see +// [reducePlotRun]). +func collectPlotData(outdir string) ([]plotRunRecord, []plotNodeCDFRecord, *benchkit.PlotEvents, error) { + manifests, err := loadRunManifests(outdir) + if err != nil { + return nil, nil, nil, err + } + var runRows []plotRunRecord + var cdfRows []plotNodeCDFRecord + var eventRuns []*benchkit.PlotRunEvents + for _, rm := range manifests { + trim, err := parseManifestTrim(rm.manifest.Trim) + if err != nil { + log.Printf(" warning: plotdata: %s trim %q: %v", filepath.Base(rm.path), rm.manifest.Trim, err) + trim = 0 + } + runs, nodes, events := reducePlotRun(outdir, rm.base, rm.manifest, trim) + runRows = append(runRows, runs...) + cdfRows = append(cdfRows, nodes...) + if events != nil { + eventRuns = append(eventRuns, events) + } + } + slices.SortFunc(runRows, func(a, b plotRunRecord) int { + return cmp.Or( + strings.Compare(a.base, b.base), + strings.Compare(a.Benchmark, b.Benchmark), + ) + }) + slices.SortFunc(cdfRows, func(a, b plotNodeCDFRecord) int { + return cmp.Or( + strings.Compare(a.base, b.base), + strings.Compare(a.Benchmark, b.Benchmark), + strings.Compare(a.node, b.node), + cmp.Compare(a.prob, b.prob), + ) + }) + return runRows, cdfRows, benchkit.PlotEvents_builder{Runs: eventRuns}.Build(), nil +} + +type loadedRunManifest struct { + base string + path string + manifest runManifest +} + +func loadRunManifests(outdir string) ([]loadedRunManifest, error) { + matches, err := filepath.Glob(filepath.Join(outdir, "*"+manifestSuffix)) + if err != nil { + return nil, err + } + var manifests []loadedRunManifest + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + log.Printf(" warning: plotdata: read %s: %v", filepath.Base(path), err) + continue + } + var m runManifest + if err := json.Unmarshal(data, &m); err != nil { + log.Printf(" warning: plotdata: parse %s: %v", filepath.Base(path), err) + continue + } + base := strings.TrimSuffix(filepath.Base(path), manifestSuffix) + manifests = append(manifests, loadedRunManifest{base: base, path: path, manifest: m}) + } + slices.SortFunc(manifests, func(a, b loadedRunManifest) int { + return strings.Compare(a.base, b.base) + }) + return manifests, nil +} + +// parseManifestTrim returns the read-time trim a run was recorded with, or 0 +// when the manifest names none. +func parseManifestTrim(trim string) (time.Duration, error) { + if trim == "" { + return 0, nil + } + return time.ParseDuration(trim) +} + +// reducePlotRun decodes one run's per-node result files and reduces them to the +// run's plot rows and its event streams. +// +// A degraded run is reduced like a successful one: its per-node data is intact +// and is what diagnoses the slow node, and its rows carry the status so +// consumers can exclude it from aggregates. A run with any other status has no +// aggregate to plot and yields no rows, but its event streams are collected all +// the same, so its throughput-over-time trace — the most informative view of a +// run that failed part way through — survives into the compact transfer. +// +// A result file that is absent is skipped silently: the nodes that crashed in a +// failed run wrote none, and a compact-transfer directory retains none for a +// successful run. Any other read or decode failure is reported. +func reducePlotRun(outdir, base string, m runManifest, trim time.Duration) ([]plotRunRecord, []plotNodeCDFRecord, *benchkit.PlotRunEvents) { + plottable := m.Status == runStatusSucceeded || m.Status == runStatusDegraded + byBench := make(map[string][]plotNodeEntry) + eventsByBench := make(map[string][]*benchkit.PlotNodeEvents) + var eventOrder []string + for _, file := range m.Files { + path := filepath.Join(outdir, file) + data, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + log.Printf(" warning: plotdata: %v", err) + } + continue + } + report, err := benchkit.DecodeReport(data) + if err != nil { + log.Printf(" warning: plotdata: parse %s: %v", filepath.Base(path), err) + continue + } + node := report.GetLabel() + if node == "" { + node = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + } + for _, r := range report.GetResults() { + cfg := r.GetConfig() + fallback := m.Dimensions + fallback.StreamMode = cmp.Or(fallback.StreamMode, "dual") + dims := cfg.DimensionsWithFallback(fallback) + name := dims.Benchmark + if name == "" { + continue + } + if events := r.GetEvents(); len(events) > 0 { + if _, seen := eventsByBench[name]; !seen { + eventOrder = append(eventOrder, name) + } + eventsByBench[name] = append(eventsByBench[name], benchkit.PlotNodeEvents_builder{ + Node: node, Events: events, + }.Build()) + } + if !plottable { + continue + } + summary := benchkit.Summarize(r, trim) + byBench[name] = append(byBench[name], plotNodeEntry{ + Dimensions: dims, + node: node, + throughput: summary.Throughput, + totalOps: r.GetTotalOps(), + failedOps: r.GetFailedOps(), + allocs: float64(r.GetAllocsPerOp()), + mem: float64(r.GetMemPerOp()), + latency: summary.Dist(), + measurementMode: cfg.GetMeasurementMode(), + }) + } + } + + runRows := make([]plotRunRecord, 0, len(byBench)) + var cdfRows []plotNodeCDFRecord + for _, bench := range slices.Sorted(maps.Keys(byBench)) { + entries := byBench[bench] + if len(entries) == 0 { + continue + } + row := aggregatePlotRun(base, m, bench, entries) + runRows = append(runRows, row) + for _, entry := range entries { + cdfRows = append(cdfRows, nodeCDFRows(base, m, bench, entry)...) + } + } + return runRows, cdfRows, runEvents(base, eventOrder, eventsByBench) +} + +// runEvents assembles one run's event streams, in first-seen benchmark order, +// or nil when no node of the run recorded an event. +func runEvents(base string, order []string, byBench map[string][]*benchkit.PlotNodeEvents) *benchkit.PlotRunEvents { + if len(order) == 0 { + return nil + } + benchmarks := make([]*benchkit.PlotBenchmarkEvents, 0, len(order)) + for _, bench := range order { + benchmarks = append(benchmarks, benchkit.PlotBenchmarkEvents_builder{ + Benchmark: bench, Nodes: byBench[bench], + }.Build()) + } + return benchkit.PlotRunEvents_builder{Base: base, Benchmarks: benchmarks}.Build() +} + +func aggregatePlotRun(base string, m runManifest, bench string, entries []plotNodeEntry) plotRunRecord { + dims := entries[0].Dimensions + row := plotRunRecord{ + Dimensions: dims, + base: base, + label: manifestLabel(m), + status: m.Status, + rep: manifestRep(m), + nodesSeen: len(entries), + } + row.Benchmark = bench + var allocs, mem float64 + var latency benchkit.LatencyDist + // Performance signal: when any node is client-measured, sum only those + // throughputs so a PBFT -client=primary run reports primary client ops/s + // rather than primary+Σbackup execute rates. Matches mergeResults + // (summary.go), which drives the printed run summary from the same + // MeasurementMode; using "has latency data" as a proxy here instead would + // disagree with that summary for a run whose server-measured backups also + // record latency samples (server-measured EXACT results do, alongside + // client-measured ones). + hasClient := false + for _, entry := range entries { + if entry.measurementMode == benchkit.MeasurementMode_CLIENT_MEASURED { + hasClient = true + break + } + } + for _, entry := range entries { + clientMeasured := entry.measurementMode == benchkit.MeasurementMode_CLIENT_MEASURED + if !hasClient || clientMeasured { + row.throughput += entry.throughput + } + row.totalOps += entry.totalOps + row.failedOps += entry.failedOps + allocs += entry.allocs + mem += entry.mem + latency.Merge(entry.latency) + } + row.allocsPerOp = allocs / float64(len(entries)) + row.memPerOp = mem / float64(len(entries)) + if !latency.Empty() { + meanUS, p50US, p95US, p99US := latencyStatsUS(&latency) + row.meanUS, row.p50US, row.p95US, row.p99US = &meanUS, &p50US, &p95US, &p99US + samples := latency.Count() + row.samples = &samples + } + return row +} + +func nodeCDFRows(base string, m runManifest, bench string, entry plotNodeEntry) []plotNodeCDFRecord { + if entry.latency.Empty() { + return nil + } + meanUS, p50US, p95US, p99US := latencyStatsUS(entry.latency) + samples := entry.latency.Count() + cdf := latencyCDFUS(entry.latency) + rows := make([]plotNodeCDFRecord, len(cdf)) + for i, v := range cdf { + rows[i] = plotNodeCDFRecord{ + Dimensions: entry.Dimensions, + base: base, + label: manifestLabel(m), + status: m.Status, + rep: manifestRep(m), + node: entry.node, + throughput: entry.throughput, + meanUS: meanUS, + p50US: p50US, + p95US: p95US, + p99US: p99US, + samples: samples, + prob: cdfProb(i), + cdfUS: v, + } + rows[i].Benchmark = bench + } + return rows +} + +func manifestLabel(m runManifest) string { + return cmp.Or(m.Label, "run") +} + +func manifestRep(m runManifest) int { + if m.Rep <= 0 { + return 1 + } + return m.Rep +} + +// latencyStatsUS returns the distribution's mean and p50, p95, and p99 +// latencies in microseconds, the unit the plot data and report figures use. +// The caller must have checked that the distribution is not empty. +func latencyStatsUS(latency *benchkit.LatencyDist) (meanUS, p50US, p95US, p99US float64) { + mean, _ := latency.MeanAndStdDev() + qs := latency.Quantiles(0.50, 0.95, 0.99) + return mean / 1e3, qs[0] / 1e3, qs[1] / 1e3, qs[2] / 1e3 +} + +// medianUS returns the distribution's median latency in microseconds, or 0 +// when it holds no samples. Zero stays distinguishable from a real reading +// because a measured median is positive. +func medianUS(latency *benchkit.LatencyDist) float64 { + if latency.Empty() { + return 0 + } + return latency.Quantiles(0.50)[0] / 1e3 +} + +// latencyCDFUS samples the distribution on the fixed CDF probability grid, +// in microseconds. The caller must have checked that it is not empty. +func latencyCDFUS(latency *benchkit.LatencyDist) []float64 { + qs := latency.Quantiles(cdfProbs()...) + out := make([]float64, len(qs)) + for i, q := range qs { + out[i] = q / 1e3 + } + return out +} + +func cdfProbs() []float64 { + probs := make([]float64, cdfPoints) + for i := range probs { + probs[i] = cdfProb(i) + } + return probs +} + +func cdfProb(i int) float64 { + return cdfProbAt(i, cdfPoints) +} + +// cdfProbAt returns the cumulative probability of point i in an n-point CDF +// sampled on the fixed, equally spaced grid i/(n-1). +func cdfProbAt(i, n int) float64 { + if n <= 1 { + return 0 + } + return float64(i) / float64(n-1) +} + +// plotRunsCSVHeader and plotRunCSVFields are shared by writePlotRunsCSV (the +// on-disk plotdata/runs.csv export) and summaryRows (explain.go's LLM triage +// prompt, which filters the same rows in memory instead of writing a file). +func plotRunsCSVHeader() []string { + header := append([]string{"base", "label", "status", "rep"}, dimensionColumns()...) + return append(header, + "throughput", "total_ops", "failed_ops", "allocs_per_op", "mem_per_op", + "nodes_seen", + "mean_us", "p50_us", "p95_us", "p99_us", + "p50_ms", "p95_ms", "p99_ms", "samples", + ) +} + +func plotRunCSVFields(row plotRunRecord) []string { + rec := append([]string{row.base, row.label, row.status, strconv.Itoa(row.rep)}, dimensionValues(row.Dimensions)...) + return append(rec, + formatFloat(row.throughput), strconv.FormatUint(row.totalOps, 10), + strconv.FormatUint(row.failedOps, 10), + formatFloat(row.allocsPerOp), formatFloat(row.memPerOp), + strconv.Itoa(row.nodesSeen), + formatFloatPtr(row.meanUS), formatFloatPtr(row.p50US), + formatFloatPtr(row.p95US), formatFloatPtr(row.p99US), + formatMillisPtr(row.p50US), formatMillisPtr(row.p95US), formatMillisPtr(row.p99US), + formatUintPtr(row.samples), + ) +} + +func writePlotRunsCSV(path string, rows []plotRunRecord) error { + return writeCSV(path, plotRunsCSVHeader(), rows, plotRunCSVFields) +} + +func writePlotNodeCDFCSV(path string, rows []plotNodeCDFRecord) error { + header := append([]string{"base", "label", "status", "rep"}, dimensionColumns()...) + header = append(header, + []string{"node", "throughput", "mean_us", "p50_us", "p95_us", + "p99_us", "p50_ms", "p95_ms", "p99_ms", "samples", "prob", "cdf_us", + }...) + return writeCSV(path, header, rows, func(row plotNodeCDFRecord) []string { + rec := append([]string{row.base, row.label, row.status, strconv.Itoa(row.rep)}, dimensionValues(row.Dimensions)...) + return append(rec, + []string{row.node, formatFloat(row.throughput), + formatFloat(row.meanUS), formatFloat(row.p50US), formatFloat(row.p95US), + formatFloat(row.p99US), formatFloat(row.p50US / 1e3), formatFloat(row.p95US / 1e3), + formatFloat(row.p99US / 1e3), strconv.FormatUint(row.samples, 10), + formatFloat(row.prob), formatFloat(row.cdfUS), + }...) + }) +} + +// plotNodeRow is one node's aggregate latency fields plus its full CDF +// vector, the unit writePlotNodesCSV exports: one row per node rather than +// one row per CDF point. Point i's cumulative probability is implied by its +// position in cdf (i/(len(cdf)-1) on the fixed CDF grid), so it is not stored. +type plotNodeRow struct { + benchkit.Dimensions + base, label, status, node string + rep int + throughput, meanUS, p50US, p95US, p99US float64 + samples uint64 + cdf []float64 +} + +// groupNodeCDFRows collapses per-CDF-point records into one plotNodeRow per +// (base, benchmark, node), assuming matching records are contiguous. This +// holds for records sourced from collectPlotData or plotRecordsFromMessage, +// both of which group by node before flattening to per-point records. +func groupNodeCDFRows(rows []plotNodeCDFRecord) []plotNodeRow { + var out []plotNodeRow + for _, r := range rows { + if n := len(out); n > 0 { + last := &out[n-1] + if last.base == r.base && last.Benchmark == r.Benchmark && last.node == r.node { + last.cdf = append(last.cdf, r.cdfUS) + continue + } + } + out = append(out, plotNodeRow{ + Dimensions: r.Dimensions, + base: r.base, label: r.label, status: r.status, rep: r.rep, + node: r.node, + throughput: r.throughput, meanUS: r.meanUS, p50US: r.p50US, p95US: r.p95US, p99US: r.p99US, + samples: r.samples, cdf: []float64{r.cdfUS}, + }) + } + return out +} + +// writePlotNodesCSV writes one human- and grep-friendly row per node, +// collapsing each node's CDF into a single space-separated cdf_us column +// instead of one row per CDF point. It is the -export-csv counterpart to the +// compact plotdata.binpb, not part of the report render path. +func writePlotNodesCSV(path string, rows []plotNodeCDFRecord) error { + header := append([]string{"base", "label", "status", "rep"}, dimensionColumns()...) + header = append(header, "node", "throughput", "mean_us", "p50_us", "p95_us", "p99_us", "samples", "cdf_us") + return writeCSV(path, header, groupNodeCDFRows(rows), func(g plotNodeRow) []string { + us := make([]string, len(g.cdf)) + for i, v := range g.cdf { + us[i] = formatFloat(v) + } + rec := append([]string{g.base, g.label, g.status, strconv.Itoa(g.rep)}, dimensionValues(g.Dimensions)...) + return append(rec, + []string{g.node, formatFloat(g.throughput), + formatFloat(g.meanUS), formatFloat(g.p50US), formatFloat(g.p95US), formatFloat(g.p99US), + strconv.FormatUint(g.samples, 10), strings.Join(us, " "), + }...) + }) +} + +func formatFloatPtr(v *float64) string { + if v == nil { + return "" + } + return formatFloat(*v) +} + +func formatMillisPtr(v *float64) string { + if v == nil { + return "" + } + return formatFloat(*v / 1e3) +} + +func formatUintPtr(v *uint64) string { + if v == nil { + return "" + } + return strconv.FormatUint(*v, 10) +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'g', -1, 64) +} + +type compactTransferSummary struct { + failedResults int + profiles int + eventBytes int64 // size of the exported events.binpb; 0 when no run recorded events +} + +// prepareCompactTransfer creates the small directory the laptop downloads for +// driver runs. It contains the reduced plot data, every run's event streams, +// manifests, logs, and failed-run result files. Successful raw result files +// remain in the driver's work directory. +func prepareCompactTransfer(outdir string, includeProfiles bool) (compactTransferSummary, error) { + var summary compactTransferSummary + if err := writeCompactPlotData(outdir); err != nil { + return summary, err + } + dst := filepath.Join(outdir, compactTransferDir) + if err := os.RemoveAll(dst); err != nil { + return summary, err + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return summary, err + } + if _, err := copyGlob(filepath.Join(outdir, "*"+manifestSuffix), dst); err != nil { + return summary, err + } + if err := copyIfExists(filepath.Join(outdir, "sweep.log"), filepath.Join(dst, "sweep.log")); err != nil { + return summary, err + } + if err := copyDirIfExists(filepath.Join(outdir, logSubdir), filepath.Join(dst, logSubdir)); err != nil { + return summary, err + } + if err := copyDir(filepath.Join(outdir, plotDataDir), filepath.Join(dst, plotDataDir)); err != nil { + return summary, err + } + if info, err := os.Stat(filepath.Join(dst, plotDataDir, plotEventsFile)); err == nil { + summary.eventBytes = info.Size() + } + n, err := copyFailedResultFiles(outdir, dst) + if err != nil { + return summary, err + } + summary.failedResults = n + if err := copyIfExists(filepath.Join(outdir, "default.pgo"), filepath.Join(dst, "default.pgo")); err != nil { + return summary, err + } + if includeProfiles { + for _, ext := range []string{cpuProfExt, memProfExt} { + n, err := copyGlob(filepath.Join(outdir, "*"+ext), dst) + if err != nil { + return summary, err + } + summary.profiles += n + } + } + return summary, nil +} + +// logCompactTransfer reports what a prepared compact transfer holds, so an +// operator sees which of its optional payloads are present before downloading. +func logCompactTransfer(outdir string, summary compactTransferSummary) { + log.Printf("compact plot data prepared in %s", displayPath(filepath.Join(outdir, compactTransferDir))) + if summary.failedResults > 0 { + log.Printf("compact transfer includes %d failed-run result file(s)", summary.failedResults) + } + if summary.eventBytes > 0 { + log.Printf("compact transfer includes %d KiB of event streams for the time-series figures", summary.eventBytes/1024) + } + if summary.profiles > 0 { + log.Printf("compact transfer includes %d profile file(s); profiles may dominate transfer size", summary.profiles) + } +} + +func copyFailedResultFiles(outdir, dst string) (int, error) { + manifests, err := loadRunManifests(outdir) + if err != nil { + return 0, err + } + var n int + for _, rm := range manifests { + if rm.manifest.Status != runStatusFailed { + continue + } + for _, file := range rm.manifest.Files { + src := filepath.Join(outdir, file) + if _, err := os.Stat(src); err != nil { + continue + } + if err := copyFile(src, filepath.Join(dst, file)); err != nil { + return n, err + } + n++ + } + } + return n, nil +} + +func copyGlob(pattern, dstDir string) (int, error) { + matches, err := filepath.Glob(pattern) + if err != nil { + return 0, err + } + for _, src := range matches { + if err := copyFile(src, filepath.Join(dstDir, filepath.Base(src))); err != nil { + return 0, err + } + } + return len(matches), nil +} + +func copyIfExists(src, dst string) error { + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return copyFile(src, dst) +} + +func copyDirIfExists(src, dst string) error { + if _, err := os.Stat(src); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return copyDir(src, dst) +} + +func copyDir(src, dst string) error { + return os.CopyFS(dst, os.DirFS(src)) +} + +func copyFile(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("%s is a directory", src) + } + return copyFileMode(src, dst, info.Mode()) +} + +func copyFileMode(src, dst string, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode.Perm()) + if err != nil { + return err + } + _, copyErr := io.Copy(out, in) + closeErr := out.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} diff --git a/benchkit/cmd/sweep/plotdata_test.go b/benchkit/cmd/sweep/plotdata_test.go new file mode 100644 index 00000000..0e61241a --- /dev/null +++ b/benchkit/cmd/sweep/plotdata_test.go @@ -0,0 +1,951 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "math" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/relab/gorums/benchkit" + "google.golang.org/protobuf/proto" +) + +func TestPlotDataExactSamples(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N2_W1_P0" + n1 := nodeAssignment{host: "bb1", port: 9000} + n2 := nodeAssignment{host: "bb2", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{ + resultFilename(base, n1, resultExt), + resultFilename(base, n2, resultExt), + }) + writePlotReport(t, dir, base, n1, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 2, 1, 0, 0), + Throughput: 10, + TotalOps: 3, + AllocsPerOp: 1, + MemPerOp: 100, + Latencies: []int64{1000, 2000}, + }.Build()) + writePlotReport(t, dir, base, n2, "bb2:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 2, 1, 0, 0), + Throughput: 20, + TotalOps: 3, + AllocsPerOp: 3, + MemPerOp: 300, + Latencies: []int64{3000, 4000}, + }.Build()) + + runs, cdf, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + if len(runs) != 1 { + t.Fatalf("runs = %d, want 1", len(runs)) + } + row := runs[0] + if row.StreamMode != "dual" { + t.Errorf("streamMode = %q, want dual", row.StreamMode) + } + if row.throughput != 30 { + t.Errorf("throughput = %v, want 30", row.throughput) + } + if row.totalOps != 6 { + t.Errorf("totalOps = %d, want 6", row.totalOps) + } + if row.allocsPerOp != 2 || row.memPerOp != 200 { + t.Errorf("cost = allocs %v mem %v, want 2 and 200", row.allocsPerOp, row.memPerOp) + } + assertFloatPtr(t, "mean_us", row.meanUS, 2.5) + assertFloatPtr(t, "p50_us", row.p50US, 2.5) + assertFloatPtr(t, "p95_us", row.p95US, 3.85) + if row.samples == nil || *row.samples != 4 { + t.Fatalf("samples = %v, want 4", row.samples) + } + if len(cdf) != 2*cdfPoints { + t.Fatalf("cdf rows = %d, want %d", len(cdf), 2*cdfPoints) + } + if cdf[0].prob != 0 || cdf[0].cdfUS != 1 { + t.Errorf("first cdf row = prob %v value %v, want 0 and 1", cdf[0].prob, cdf[0].cdfUS) + } + if cdf[0].StreamMode != "dual" { + t.Errorf("cdf streamMode = %q, want dual", cdf[0].StreamMode) + } + if last := cdf[cdfPoints-1]; last.prob != 1 || last.cdfUS != 2 { + t.Errorf("last bb1 cdf row = prob %v value %v, want 1 and 2", last.prob, last.cdfUS) + } +} + +// TestAggregatePlotRunUsesMeasurementMode verifies that aggregatePlotRun +// sums throughput from client-measured nodes only when any exist, matching +// mergeResults (summary.go) exactly: a PBFT-style primary-client run reports +// the primary's client ops/s, not primary+Σbackup execute rates, even though +// the server-measured backups here also carry latency samples (server +// measured EXACT results do) — the case that defeats a "has latency data" +// proxy for "client-measured". +func TestAggregatePlotRunUsesMeasurementMode(t *testing.T) { + entries := []plotNodeEntry{ + { + node: "primary", throughput: 5000, + measurementMode: benchkit.MeasurementMode_CLIENT_MEASURED, + latency: benchkit.Summary{Latencies: []int64{100, 200}, LatencyValid: true}.Dist(), + }, + { + node: "backup1", throughput: 50000, + measurementMode: benchkit.MeasurementMode_SERVER_MEASURED, + latency: benchkit.Summary{Latencies: []int64{80, 90}, LatencyValid: true}.Dist(), + }, + { + node: "backup2", throughput: 50000, + measurementMode: benchkit.MeasurementMode_SERVER_MEASURED, + latency: benchkit.Summary{Latencies: []int64{80, 90}, LatencyValid: true}.Dist(), + }, + } + row := aggregatePlotRun("base", runManifest{}, "PBFT", entries) + if row.throughput != 5000 { + t.Errorf("throughput = %v, want 5000 (primary client ops/s only, not primary+backups)", row.throughput) + } +} + +// TestAggregatePlotRunSumsAllWhenNoneClientMeasured verifies the fallback: +// when no node is client-measured (a symmetric multi-node client), every +// node's throughput is summed. +func TestAggregatePlotRunSumsAllWhenNoneClientMeasured(t *testing.T) { + entries := []plotNodeEntry{ + {node: "n1", throughput: 5000, measurementMode: benchkit.MeasurementMode_SERVER_MEASURED}, + {node: "n2", throughput: 6000, measurementMode: benchkit.MeasurementMode_SERVER_MEASURED}, + } + row := aggregatePlotRun("base", runManifest{}, "Multicast", entries) + if row.throughput != 11000 { + t.Errorf("throughput = %v, want 11000 (sum across symmetric multi-node clients)", row.throughput) + } +} + +// TestPlotDataStreamMode verifies that the stream mode recorded in the run +// manifest propagates to both the run rows and the per-node CDF rows, so +// dedup and dual runs remain distinguishable plot dimensions. +func TestPlotDataStreamMode(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N2_W1_P0_Sdedup_r1" + n1 := nodeAssignment{host: "bb1", port: 9000} + n2 := nodeAssignment{host: "bb2", port: 9000} + writePlotManifestWithStreamMode(t, dir, base, runStatusSucceeded, 1, "", "dedup", []string{ + resultFilename(base, n1, resultExt), + resultFilename(base, n2, resultExt), + }) + writePlotReport(t, dir, base, n1, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfigWithStreamMode("Q", 2, 1, 0, 0, "dedup"), + Throughput: 10, + Latencies: []int64{1000}, + }.Build()) + writePlotReport(t, dir, base, n2, "bb2:9000", benchkit.Result_builder{ + Config: plotRunConfigWithStreamMode("Q", 2, 1, 0, 0, "dedup"), + Throughput: 20, + Latencies: []int64{2000}, + }.Build()) + + runs, cdf, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + if len(runs) != 1 { + t.Fatalf("runs = %d, want 1", len(runs)) + } + if got := runs[0].StreamMode; got != "dedup" { + t.Fatalf("streamMode = %q, want dedup", got) + } + if len(cdf) == 0 { + t.Fatal("cdf rows = 0, want rows") + } + if cdf[0].StreamMode != "dedup" { + t.Fatalf("cdf streamMode = %q, want dedup", cdf[0].StreamMode) + } +} + +func TestPlotDataHDRHistograms(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N2_W1_P0" + n1 := nodeAssignment{host: "bb1", port: 9000} + n2 := nodeAssignment{host: "bb2", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{ + resultFilename(base, n1, resultExt), + resultFilename(base, n2, resultExt), + }) + writePlotReport(t, dir, base, n1, "bb1", benchkit.Result_builder{ + Config: plotRunConfigWithStats("Q", 2, 1, 0, 0, benchkit.StatsMode_HDR), + Histogram: benchkit.LatencyHistogram_builder{Value: []int64{100, 200}, Count: []uint64{5, 10}}.Build(), + }.Build()) + writePlotReport(t, dir, base, n2, "bb2", benchkit.Result_builder{ + Config: plotRunConfigWithStats("Q", 2, 1, 0, 0, benchkit.StatsMode_HDR), + Histogram: benchkit.LatencyHistogram_builder{Value: []int64{200, 400}, Count: []uint64{10, 15}}.Build(), + }.Build()) + + runs, cdf, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + row := runs[0] + assertFloatPtr(t, "mean_us", row.meanUS, 0.2625) + assertFloatPtr(t, "p50_us", row.p50US, 0.2) + assertFloatPtr(t, "p95_us", row.p95US, 0.4) + if row.samples == nil || *row.samples != 40 { + t.Fatalf("samples = %v, want 40", row.samples) + } + if len(cdf) != 2*cdfPoints { + t.Fatalf("cdf rows = %d, want %d", len(cdf), 2*cdfPoints) + } +} + +func TestPlotDataTrim(t *testing.T) { + const s = int64(1_000_000_000) + dir := t.TempDir() + base := "e1_Q_N1_W1_P0" + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "1s", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 999, + Latencies: make([]int64, 65), + Events: []*benchkit.Event{ + tputEvent(0, 5), + tputEvent(1*s, 10), + tputEvent(2*s, 20), + tputEvent(3*s, 30), + }, + }.Build()) + + runs, _, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + row := runs[0] + if row.throughput != 20 { + t.Errorf("throughput = %v, want 20", row.throughput) + } + if row.samples == nil || *row.samples != 60 { + t.Fatalf("samples = %v, want 60", row.samples) + } +} + +func TestPlotDataRepetitionsRemainSeparate(t *testing.T) { + dir := t.TempDir() + for rep := 1; rep <= 2; rep++ { + base := "e1_Q_N1_W1_P0" + if rep == 2 { + base += "_r2" + } + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, rep, "", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: float64(rep), + Latencies: []int64{1000}, + }.Build()) + } + + runs, _, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + if len(runs) != 2 { + t.Fatalf("runs = %d, want 2", len(runs)) + } + if runs[0].rep != 1 || runs[1].rep != 2 { + t.Fatalf("reps = %d, %d; want 1, 2", runs[0].rep, runs[1].rep) + } +} + +// TestPlotDataIncludesDegradedRuns verifies that degraded runs (completed but +// with a pathologically slow node) flow into the plot data tagged with their +// status, so consumers can exclude them from aggregates while still diagnosing +// the slow node, and that failed runs remain excluded. +func TestPlotDataIncludesDegradedRuns(t *testing.T) { + dir := t.TempDir() + node := nodeAssignment{host: "bb1", port: 9000} + bases := []struct { + base string + status string + }{ + {"e1_Q_N1_W1_P0", runStatusSucceeded}, + {"e1_Q_N1_W1_P0_r2", runStatusDegraded}, + {"e1_Q_N1_W1_P0_r3", runStatusFailed}, + } + for i, b := range bases { + writePlotManifest(t, dir, b.base, b.status, i+1, "", []string{resultFilename(b.base, node, resultExt)}) + writePlotReport(t, dir, b.base, node, "bb1", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 10, + Latencies: []int64{1000}, + }.Build()) + } + + runs, cdf, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + if len(runs) != 2 { + t.Fatalf("runs = %d, want 2 (succeeded + degraded, failed excluded)", len(runs)) + } + if runs[0].status != runStatusSucceeded || runs[1].status != runStatusDegraded { + t.Errorf("statuses = %q, %q; want %q, %q", + runs[0].status, runs[1].status, runStatusSucceeded, runStatusDegraded) + } + if len(cdf) != 2*cdfPoints { + t.Fatalf("cdf rows = %d, want %d", len(cdf), 2*cdfPoints) + } + if cdf[0].status != runStatusSucceeded || cdf[len(cdf)-1].status != runStatusDegraded { + t.Errorf("cdf statuses = %q, %q; want %q, %q", + cdf[0].status, cdf[len(cdf)-1].status, runStatusSucceeded, runStatusDegraded) + } +} + +// TestCollectPlotDataEventsCoverEveryStatus verifies that the event streams are +// collected for every run regardless of outcome, including a failed run that +// contributes no plot row: a failed run's throughput-over-time trace is what +// shows whether its nodes were producing work before the failure, and the raw +// file it would otherwise have to come from is not retained for a successful +// run at all. +func TestCollectPlotDataEventsCoverEveryStatus(t *testing.T) { + const s = int64(1_000_000_000) + dir := t.TempDir() + node := nodeAssignment{host: "bb1", port: 9000} + statuses := map[string]string{ + "e1_Q_N1_W1_P0": runStatusSucceeded, + "e1_Q_N1_W1_P0_r2": runStatusDegraded, + "e1_Q_N1_W1_P0_r3": runStatusFailed, + } + for base, status := range statuses { + writePlotManifest(t, dir, base, status, 1, "", []string{resultFilename(base, node, resultExt)}) + writePlotReport(t, dir, base, node, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 10, + Latencies: []int64{1000}, + Events: []*benchkit.Event{tputEvent(0, 5), tputEvent(1*s, 10)}, + }.Build()) + } + + runs, _, events, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + if len(runs) != 2 { + t.Errorf("plot rows = %d, want 2 (failed run excluded)", len(runs)) + } + got := map[string]int{} + for _, run := range events.GetRuns() { + benches := run.GetBenchmarks() + if len(benches) != 1 || benches[0].GetBenchmark() != "Q" { + t.Fatalf("%s benchmarks = %+v, want one entry for Q", run.GetBase(), benches) + } + nodes := benches[0].GetNodes() + if len(nodes) != 1 || nodes[0].GetNode() != "bb1:9000" { + t.Fatalf("%s nodes = %+v, want one entry for bb1:9000", run.GetBase(), nodes) + } + got[run.GetBase()] = len(nodes[0].GetEvents()) + } + for base := range statuses { + if got[base] != 2 { + t.Errorf("%s (%s) events = %d, want 2", base, statuses[base], got[base]) + } + } +} + +// TestWriteCompactPlotDataEventsFile verifies the events.binpb round trip: it is +// written beside plotdata.binpb when any run recorded events, is absent when no +// run did (interval reporting off), and a stale one is removed by a re-export. +func TestWriteCompactPlotDataEventsFile(t *testing.T) { + const s = int64(1_000_000_000) + dir := t.TempDir() + base := "e1_Q_N1_W1_P0" + node := nodeAssignment{host: "bb1", port: 9000} + writeRun := func(events ...*benchkit.Event) { + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, node, resultExt)}) + writePlotReport(t, dir, base, node, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 10, + Latencies: []int64{1000}, + Events: events, + }.Build()) + } + + writeRun(tputEvent(0, 5), tputEvent(1*s, 10)) + if err := writeCompactPlotData(dir); err != nil { + t.Fatalf("writeCompactPlotData: %v", err) + } + events, err := readPlotEvents(dir) + if err != nil { + t.Fatalf("readPlotEvents: %v", err) + } + if len(events.GetRuns()) != 1 || events.GetRuns()[0].GetBase() != base { + t.Fatalf("event runs = %+v, want one entry for %s", events.GetRuns(), base) + } + + // Re-exporting a run with no events at all must not leave the stale file. + writeRun() + if err := writeCompactPlotData(dir); err != nil { + t.Fatalf("writeCompactPlotData: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, plotDataDir, plotEventsFile)); !os.IsNotExist(err) { + t.Errorf("events.binpb present for a sweep with no events: %v", err) + } + events, err = readPlotEvents(dir) + if err != nil { + t.Fatalf("readPlotEvents with no events file: %v", err) + } + if events != nil { + t.Errorf("readPlotEvents = %+v, want nil", events) + } +} + +// TestPrepareCompactTransferRebuildsFromRawResults verifies the -export-compact +// rescue path: re-running the preparation over a work directory whose earlier +// compact transfer predates the event streams replaces that directory, so the +// small download carries the events without shipping the raw archive. +func TestPrepareCompactTransferRebuildsFromRawResults(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N1_W1_P0" + node := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, node, resultExt)}) + writePlotReport(t, dir, base, node, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 10, + Latencies: []int64{1000}, + Events: []*benchkit.Event{tputEvent(0, 5), tputEvent(1_000_000_000, 10)}, + }.Build()) + + // An earlier transfer directory, holding plot data but no event streams. + stale := filepath.Join(dir, compactTransferDir, plotDataDir) + if err := os.MkdirAll(stale, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stale, plotDataFile), nil, 0o644); err != nil { + t.Fatal(err) + } + + summary, err := prepareCompactTransfer(dir, false) + if err != nil { + t.Fatalf("prepareCompactTransfer: %v", err) + } + if summary.eventBytes <= 0 { + t.Errorf("eventBytes = %d, want > 0", summary.eventBytes) + } + events := filepath.Join(dir, compactTransferDir, plotDataDir, plotEventsFile) + info, err := os.Stat(events) + if err != nil { + t.Fatalf("events.binpb missing from the rebuilt transfer: %v", err) + } + if info.Size() != summary.eventBytes { + t.Errorf("events.binpb is %d bytes, want the reported %d", info.Size(), summary.eventBytes) + } +} + +func TestPrepareCompactTransferExcludesSuccessfulRawResults(t *testing.T) { + dir := t.TempDir() + successBase := "e1_Q_N1_W1_P0" + failedBase := "e1_Q_N2_W1_P0" + successNode := nodeAssignment{host: "bb1", port: 9000} + failedNode := nodeAssignment{host: "bb2", port: 9000} + writePlotManifest(t, dir, successBase, runStatusSucceeded, 1, "", []string{resultFilename(successBase, successNode, resultExt)}) + writePlotManifest(t, dir, failedBase, runStatusFailed, 1, "", []string{resultFilename(failedBase, failedNode, resultExt)}) + writePlotReport(t, dir, successBase, successNode, "bb1", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 1, + Latencies: []int64{1000}, + }.Build()) + writePlotReport(t, dir, failedBase, failedNode, "bb2", benchkit.Result_builder{ + Config: plotRunConfig("Q", 2, 1, 0, 0), + Throughput: 2, + Latencies: []int64{2000}, + }.Build()) + if err := os.WriteFile(filepath.Join(dir, "sweep.log"), []byte("log\n"), 0o644); err != nil { + t.Fatalf("write sweep.log: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, logSubdir), 0o755); err != nil { + t.Fatalf("mkdir logs: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, logSubdir, successBase+".log"), []byte("node log\n"), 0o644); err != nil { + t.Fatalf("write node log: %v", err) + } + + summary, err := prepareCompactTransfer(dir, false) + if err != nil { + t.Fatalf("prepareCompactTransfer: %v", err) + } + if summary.failedResults != 1 { + t.Fatalf("failedResults = %d, want 1", summary.failedResults) + } + transfer := filepath.Join(dir, compactTransferDir) + if _, err := os.Stat(filepath.Join(transfer, resultFilename(failedBase, failedNode, resultExt))); err != nil { + t.Fatalf("failed result missing from compact transfer: %v", err) + } + if _, err := os.Stat(filepath.Join(transfer, resultFilename(successBase, successNode, resultExt))); !os.IsNotExist(err) { + t.Fatalf("successful result should not be in compact transfer: %v", err) + } + if _, err := os.Stat(filepath.Join(transfer, plotDataDir, plotDataFile)); err != nil { + t.Fatalf("plotdata.binpb missing: %v", err) + } +} + +func writePlotManifest(t *testing.T, dir, base, status string, rep int, trim string, files []string) { + writePlotManifestWithStreamMode(t, dir, base, status, rep, trim, "", files) +} + +func writePlotManifestWithStreamMode(t *testing.T, dir, base, status string, rep int, trim, streamMode string, files []string) { + t.Helper() + writePlotManifestDims(t, dir, base, status, rep, trim, benchkit.Dimensions{ + Benchmark: "Q", Nodes: 1, Workers: 1, StreamMode: streamMode, + }, nil, files) +} + +// writePlotManifestDims writes a run manifest for the given configuration and +// node hosts, for tests of the selection logic that groups runs by either. +func writePlotManifestDims(t *testing.T, dir, base, status string, rep int, trim string, dims benchkit.Dimensions, hosts, files []string) { + t.Helper() + m := runManifest{ + runSpec: runSpec{Dimensions: dims, Rep: rep}, + Label: "e1", + Trim: trim, + Status: status, + Hosts: hosts, + Files: files, + } + data, err := json.MarshalIndent(&m, "", " ") + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, base+manifestSuffix), append(data, '\n'), 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } +} + +func writePlotReport(t *testing.T, dir, base string, node nodeAssignment, label string, results ...*benchkit.Result) { + t.Helper() + path := filepath.Join(dir, resultFilename(base, node, resultExt)) + if err := benchkit.WriteLabeledReport(results, label, path); err != nil { + t.Fatalf("write report: %v", err) + } +} + +// writePlotEventsFile writes events as the sweep directory's +// plotdata/events.binpb, the form a compact transfer carries. +func writePlotEventsFile(t *testing.T, dir string, events *benchkit.PlotEvents) { + t.Helper() + plotdataDir := filepath.Join(dir, plotDataDir) + if err := os.MkdirAll(plotdataDir, 0o755); err != nil { + t.Fatal(err) + } + data, err := proto.Marshal(events) + if err != nil { + t.Fatalf("marshal events: %v", err) + } + if err := os.WriteFile(filepath.Join(plotdataDir, plotEventsFile), data, 0o644); err != nil { + t.Fatalf("write events: %v", err) + } +} + +// tputEvent is one throughput-interval event: ops completed over the one second +// ending at offsetNs. +func tputEvent(offsetNs int64, ops uint64) *benchkit.Event { + return benchkit.Event_builder{ + Offset: offsetNs, + Throughput: benchkit.ThroughputInterval_builder{Ops: ops, Duration: 1_000_000_000}.Build(), + }.Build() +} + +func plotRunConfig(name string, nodes, workers, payload, rate int32) *benchkit.RunConfig { + return plotRunConfigWithStats(name, nodes, workers, payload, rate, benchkit.StatsMode_EXACT) +} + +func plotRunConfigWithStats(name string, nodes, workers, payload, rate int32, statsMode benchkit.StatsMode) *benchkit.RunConfig { + return benchkit.RunConfig_builder{ + Name: name, + NumNodes: nodes, + Workers: workers, + Payload: payload, + Rate: int64(rate), + StatsMode: statsMode, + }.Build() +} + +func plotRunConfigWithStreamMode(name string, nodes, workers, payload, rate int32, streamMode string) *benchkit.RunConfig { + return benchkit.RunConfig_builder{ + Name: name, + NumNodes: nodes, + Workers: workers, + Payload: payload, + Rate: int64(rate), + StreamMode: streamMode, + }.Build() +} + +// TestBuildPlotDataNormalizesIdentity verifies that buildPlotData groups flat +// run and per-node CDF records into the nested message with run and node +// identity stored once, and that plotRecordsFromMessage flattens it back to +// equivalent records. +func TestBuildPlotDataNormalizesIdentity(t *testing.T) { + meanUS, p50US, p95US, p99US := 100.0, 90.0, 150.0, 200.0 + samples := uint64(42) + runs := []plotRunRecord{{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 2, Workers: 4, Payload: 128, StreamMode: "dual", + }, + base: "run1", label: "e1", status: runStatusSucceeded, rep: 1, + throughput: 30, totalOps: 6, failedOps: 0, allocsPerOp: 2, memPerOp: 200, nodesSeen: 2, + meanUS: &meanUS, p50US: &p50US, p95US: &p95US, p99US: &p99US, samples: &samples, + }} + cdf := []plotNodeCDFRecord{ + {Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 2, Workers: 4, Payload: 128, StreamMode: "dual"}, + base: "run1", label: "e1", status: runStatusSucceeded, rep: 1, + node: "bb1:9000", throughput: 10, meanUS: 100, p50US: 90, p95US: 150, p99US: 200, samples: 2, + prob: 0, cdfUS: 1}, + {Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 2, Workers: 4, Payload: 128, StreamMode: "dual"}, + base: "run1", label: "e1", status: runStatusSucceeded, rep: 1, + node: "bb1:9000", throughput: 10, meanUS: 100, p50US: 90, p95US: 150, p99US: 200, samples: 2, + prob: 1, cdfUS: 2}, + } + + pd := buildPlotData(runs, cdf) + if len(pd.GetRuns()) != 1 { + t.Fatalf("runs = %d, want 1", len(pd.GetRuns())) + } + run := pd.GetRuns()[0] + if run.GetBase() != "run1" || run.GetLabel() != "e1" || run.GetRep() != 1 { + t.Errorf("run identity = %q %q %d, want run1 e1 1", run.GetBase(), run.GetLabel(), run.GetRep()) + } + if len(run.GetBenchmarks()) != 1 { + t.Fatalf("benchmarks = %d, want 1", len(run.GetBenchmarks())) + } + bench := run.GetBenchmarks()[0] + // The benchmark name and sweep dimensions ride in a RunConfig, the same + // type the raw result files carry them in. + cfg := bench.GetConfig() + if cfg.GetName() != "Q" || cfg.GetNumNodes() != 2 || cfg.GetWorkers() != 4 || cfg.GetStreamMode() != "dual" { + t.Errorf("config = %+v, want name=Q nodes=2 workers=4 streamMode=dual", cfg) + } + if bench.GetThroughput() != 30 { + t.Errorf("throughput = %v, want 30", bench.GetThroughput()) + } + if len(bench.GetNodes()) != 1 { + t.Fatalf("nodes = %d, want 1", len(bench.GetNodes())) + } + node := bench.GetNodes()[0] + if node.GetNode() != "bb1:9000" { + t.Errorf("node = %q, want bb1:9000", node.GetNode()) + } + if got := node.GetCdfUs(); len(got) != 2 || got[0] != 1 || got[1] != 2 { + t.Errorf("cdf_us = %v, want [1 2]", got) + } + + gotRuns, gotCDF := plotRecordsFromMessage(pd) + if len(gotRuns) != 1 || gotRuns[0].base != "run1" || gotRuns[0].throughput != 30 { + t.Errorf("round-tripped runs = %+v", gotRuns) + } + if gotRuns[0].samples == nil || *gotRuns[0].samples != 42 { + t.Errorf("round-tripped samples = %v, want 42", gotRuns[0].samples) + } + if len(gotCDF) != 2 { + t.Fatalf("round-tripped cdf rows = %d, want 2", len(gotCDF)) + } + if gotCDF[0].prob != 0 || gotCDF[0].cdfUS != 1 || gotCDF[0].node != "bb1:9000" { + t.Errorf("round-tripped cdf[0] = %+v", gotCDF[0]) + } + if gotCDF[1].prob != 1 || gotCDF[1].cdfUS != 2 { + t.Errorf("round-tripped cdf[1] = %+v", gotCDF[1]) + } +} + +// TestBuildPlotDataOmitsSummaryWithoutSamples verifies that a benchmark with +// no latency data round-trips as a nil summary, not a spurious all-zero one, +// preserving the flat record's nil-pointer distinction. +func TestBuildPlotDataOmitsSummaryWithoutSamples(t *testing.T) { + runs := []plotRunRecord{{base: "run1", Dimensions: benchkit.Dimensions{Benchmark: "Q"}, throughput: 5}} + pd := buildPlotData(runs, nil) + if s := pd.GetRuns()[0].GetBenchmarks()[0].GetSummary(); s != nil { + t.Errorf("summary = %+v, want nil", s) + } + gotRuns, _ := plotRecordsFromMessage(pd) + if gotRuns[0].meanUS != nil || gotRuns[0].samples != nil { + t.Errorf("meanUS = %v samples = %v, want nil", gotRuns[0].meanUS, gotRuns[0].samples) + } +} + +// TestPlotDataMarshalRoundTrip verifies that the message buildPlotData +// produces survives a protobuf marshal/unmarshal cycle unchanged. +func TestPlotDataMarshalRoundTrip(t *testing.T) { + runs := []plotRunRecord{{ + base: "run1", Dimensions: benchkit.Dimensions{Benchmark: "Q", StreamMode: "dual"}, throughput: 5, + }} + want := buildPlotData(runs, nil) + data, err := proto.Marshal(want) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := &benchkit.PlotData{} + if err := proto.Unmarshal(data, got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !proto.Equal(want, got) { + t.Errorf("round-tripped message differs:\nwant %v\ngot %v", want, got) + } +} + +// TestWriteCompactPlotDataWritesBinpb verifies that writeCompactPlotData +// writes the normalized plotdata.binpb instead of the old runs.csv and +// node_cdf.csv pair, and that readPlotData decodes it back into the same +// records collectPlotData produced. +func TestWriteCompactPlotDataWritesBinpb(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N1_W1_P0" + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 10, + Latencies: []int64{1000, 2000}, + }.Build()) + + wantRuns, wantCDF, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + + if err := writeCompactPlotData(dir); err != nil { + t.Fatalf("writeCompactPlotData: %v", err) + } + plotdataDir := filepath.Join(dir, plotDataDir) + if _, err := os.Stat(filepath.Join(plotdataDir, plotDataFile)); err != nil { + t.Fatalf("plotdata.binpb missing: %v", err) + } + for _, legacy := range []string{"runs.csv", "node_cdf.csv"} { + if _, err := os.Stat(filepath.Join(plotdataDir, legacy)); !os.IsNotExist(err) { + t.Errorf("legacy %s should not be written, stat err = %v", legacy, err) + } + } + + pd, err := readPlotData(dir) + if err != nil { + t.Fatalf("readPlotData: %v", err) + } + gotRuns, gotCDF := plotRecordsFromMessage(pd) + if len(gotRuns) != len(wantRuns) || len(gotCDF) != len(wantCDF) { + t.Fatalf("round-tripped %d runs / %d cdf rows, want %d / %d", + len(gotRuns), len(gotCDF), len(wantRuns), len(wantCDF)) + } + if gotRuns[0].base != wantRuns[0].base || gotRuns[0].throughput != wantRuns[0].throughput { + t.Errorf("run mismatch: got %+v, want %+v", gotRuns[0], wantRuns[0]) + } + if gotCDF[0].cdfUS != wantCDF[0].cdfUS || gotCDF[len(gotCDF)-1].cdfUS != wantCDF[len(wantCDF)-1].cdfUS { + t.Errorf("cdf mismatch: got first/last %v/%v, want %v/%v", + gotCDF[0].cdfUS, gotCDF[len(gotCDF)-1].cdfUS, wantCDF[0].cdfUS, wantCDF[len(wantCDF)-1].cdfUS) + } +} + +// TestWritePlotNodesCSV verifies that the exported per-node CSV collapses +// each node's CDF into a single row with a space-joined vector column, +// instead of one row per CDF point. +func TestWritePlotNodesCSV(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nodes.csv") + rows := []plotNodeCDFRecord{ + {base: "run1", Dimensions: benchkit.Dimensions{Benchmark: "Q"}, node: "bb1:9000", throughput: 10, + meanUS: 100, p50US: 90, p95US: 150, p99US: 200, samples: 2, prob: 0, cdfUS: 1}, + {base: "run1", Dimensions: benchkit.Dimensions{Benchmark: "Q"}, node: "bb1:9000", throughput: 10, + meanUS: 100, p50US: 90, p95US: 150, p99US: 200, samples: 2, prob: 0.5, cdfUS: 1.5}, + {base: "run1", Dimensions: benchkit.Dimensions{Benchmark: "Q"}, node: "bb1:9000", throughput: 10, + meanUS: 100, p50US: 90, p95US: 150, p99US: 200, samples: 2, prob: 1, cdfUS: 2}, + {base: "run1", Dimensions: benchkit.Dimensions{Benchmark: "Q"}, node: "bb2:9000", throughput: 20, + meanUS: 110, p50US: 95, p95US: 160, p99US: 210, samples: 3, prob: 0, cdfUS: 3}, + } + if err := writePlotNodesCSV(path, rows); err != nil { + t.Fatalf("writePlotNodesCSV: %v", err) + } + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + recs, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatal(err) + } + if len(recs) != 3 { + t.Fatalf("rows = %d incl header, want 3 (header + one per node)", len(recs)) + } + header := recs[0] + nodeCol, cdfCol := slices.Index(header, "node"), slices.Index(header, "cdf_us") + if nodeCol < 0 || cdfCol < 0 { + t.Fatalf("header %v missing node or cdf_us column", header) + } + if recs[1][nodeCol] != "bb1:9000" { + t.Fatalf("row 1 node = %q, want bb1:9000", recs[1][nodeCol]) + } + if want := "1 1.5 2"; recs[1][cdfCol] != want { + t.Errorf("row 1 cdf_us = %q, want %q", recs[1][cdfCol], want) + } + if recs[2][nodeCol] != "bb2:9000" { + t.Fatalf("row 2 node = %q, want bb2:9000", recs[2][nodeCol]) + } + if want := "3"; recs[2][cdfCol] != want { + t.Errorf("row 2 cdf_us = %q, want %q", recs[2][cdfCol], want) + } +} + +// TestExportPlotCSV verifies that exportPlotCSV regenerates plotdata/runs.csv +// and plotdata/nodes.csv from a collected directory's plotdata.binpb. +func TestExportPlotCSV(t *testing.T) { + dir := t.TempDir() + base := "e1_Q_N1_W1_P0" + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 10, + Latencies: []int64{1000, 2000}, + }.Build()) + if err := writeCompactPlotData(dir); err != nil { + t.Fatalf("writeCompactPlotData: %v", err) + } + + if err := exportPlotCSV(dir); err != nil { + t.Fatalf("exportPlotCSV: %v", err) + } + plotdataDir := filepath.Join(dir, plotDataDir) + runs, err := readPlotRunsCSV(filepath.Join(plotdataDir, "runs.csv")) + if err != nil { + t.Fatalf("readPlotRunsCSV: %v", err) + } + if len(runs) != 1 || runs[0].base != base || runs[0].throughput != 10 { + t.Errorf("exported runs = %+v", runs) + } + f, err := os.Open(filepath.Join(plotdataDir, "nodes.csv")) + if err != nil { + t.Fatalf("open nodes.csv: %v", err) + } + defer f.Close() + recs, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 { + t.Fatalf("nodes.csv rows = %d incl header, want 2 (header + one node)", len(recs)) + } + nodeCol, cdfCol := slices.Index(recs[0], "node"), slices.Index(recs[0], "cdf_us") + if recs[1][nodeCol] != "bb1:9000" { + t.Errorf("node = %q, want bb1:9000", recs[1][nodeCol]) + } + if got := len(strings.Fields(recs[1][cdfCol])); got != cdfPoints { + t.Errorf("cdf_us has %d points, want %d", got, cdfPoints) + } +} + +func assertFloatPtr(t *testing.T, name string, got *float64, want float64) { + t.Helper() + if got == nil { + t.Fatalf("%s = nil, want %v", name, want) + } + if math.Abs(*got-want) > 1e-9 { + t.Fatalf("%s = %v, want %v", name, *got, want) + } +} + +// TestPlotDataRoundTripPreservesBufferSizes verifies that the buffer capacities +// survive the reduction into plot data and the read back out. They are what +// separates the arms of a buffer sweep, so losing them here collapses every arm +// into one aggregate row without any error. +func TestPlotDataRoundTripPreservesBufferSizes(t *testing.T) { + runs := []plotRunRecord{ + {base: "s_Q_N3_W1_P0_RB0_Sdual_r1", Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 3, Workers: 1, StreamMode: "dual"}, throughput: 100}, + {base: "s_Q_N3_W1_P0_RB16_Sdual_r1", Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 3, Workers: 1, StreamMode: "dual", RecvBuffer: 16}, throughput: 200}, + {base: "s_Q_N3_W1_P0_SB64_Sdual_r1", Dimensions: benchkit.Dimensions{Benchmark: "Q", Nodes: 3, Workers: 1, StreamMode: "dual", SendBuffer: 64}, throughput: 300}, + } + pd := buildPlotData(runs, nil) + got := map[[2]int]bool{} + for _, r := range pd.GetRuns() { + for _, b := range r.GetBenchmarks() { + c := b.GetConfig() + got[[2]int{int(c.GetSendBuffer()), int(c.GetRecvBuffer())}] = true + } + } + for _, want := range [][2]int{{0, 0}, {0, 16}, {64, 0}} { + if !got[want] { + t.Errorf("plot data lost buffer capacities send=%d recv=%d; got %v", want[0], want[1], got) + } + } + + // The arms must stay distinct all the way into the aggregate rows. + back, _ := plotRecordsFromMessage(pd) + if n := len(aggregateReps(back, false)); n != len(runs) { + t.Errorf("aggregated to %d rows, want %d: buffer arms were folded together", n, len(runs)) + } +} + +// TestCollectPlotDataPreservesBufferSizes verifies that the buffer capacities +// survive the reduction from raw result files, the hop where the report pipeline +// actually starts. A round trip that begins at a plotRunRecord cannot see a loss +// here, which is how a collapsed buffer sweep reached a report unnoticed. +func TestCollectPlotDataPreservesBufferSizes(t *testing.T) { + dir := t.TempDir() + arms := []struct { + base string + recvBuffer int32 + throughput float64 + }{ + {"s_QuorumCall_N3_W1_P0_RB0_Sdual_r1", 0, 100}, + {"s_QuorumCall_N3_W1_P0_RB16_Sdual_r1", 16, 200}, + {"s_QuorumCall_N3_W1_P0_RB256_Sdual_r1", 256, 300}, + } + for _, a := range arms { + results := []*benchkit.Result{ + benchkit.Result_builder{ + Config: benchkit.RunConfig_builder{ + Name: "QuorumCall", NumNodes: 3, Workers: 1, + RecvBuffer: a.recvBuffer, StreamMode: "dual", + }.Build(), + Throughput: a.throughput, + TotalOps: 1000, + }.Build(), + } + path := filepath.Join(dir, a.base+"_n1_9000"+resultExt) + if err := benchkit.WriteLabeledReport(results, "n1", path); err != nil { + t.Fatalf("write result: %v", err) + } + m := runManifest{ + runSpec: runSpec{ + Dimensions: benchkit.Dimensions{ + Benchmark: "QuorumCall", Nodes: 3, Workers: 1, StreamMode: "dual", + }, + Rep: 1, + }, + Label: "s", Status: runStatusSucceeded, + Files: []string{a.base + "_n1_9000" + resultExt}, + } + blob, err := json.MarshalIndent(m, "", " ") + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, a.base+manifestSuffix), blob, 0o644); err != nil { + t.Fatalf("write manifest: %v", err) + } + } + + runs, _, _, err := collectPlotData(dir) + if err != nil { + t.Fatalf("collectPlotData: %v", err) + } + got := map[int]bool{} + for _, r := range runs { + got[r.RecvBuffer] = true + } + for _, want := range []int{0, 16, 256} { + if !got[want] { + t.Errorf("reduction lost recv buffer %d; got %v", want, got) + } + } + if n := len(aggregateReps(runs, false)); n != len(arms) { + t.Errorf("aggregated to %d rows, want %d: the arms were folded together", n, len(arms)) + } +} diff --git a/benchkit/cmd/sweep/plotmode.go b/benchkit/cmd/sweep/plotmode.go new file mode 100644 index 00000000..ad55ac40 --- /dev/null +++ b/benchkit/cmd/sweep/plotmode.go @@ -0,0 +1,879 @@ +package main + +import ( + "cmp" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/relab/gorums/benchkit" +) + +// reportSubdir is the directory, under a sweep output directory, that holds the +// generated CSVs, report.typ, the copied helper library, and the compiled PDF. +const reportSubdir = "report" + +// maxCDFRuns caps how many runs the per-node latency CDF grid draws a panel +// for, so a large sweep does not fill its report with them. Fifteen fills a page +// at three panels per row. +const maxCDFRuns = 15 + +// maxTimeSeriesRuns caps how many runs get an over-time figure. These are +// per-run traces read one at a time rather than compared side by side, so they +// warrant fewer than the CDF grid's panels. +const maxTimeSeriesRuns = 6 + +// offsetCDFPoints is the number of quantiles sampled per clock-offset CDF curve. +const offsetCDFPoints = 120 + +// reportOptions carries the post-hoc filtering choices a report honors. +type reportOptions struct { + title string + includeDegraded bool + excludeRuns map[string]bool // run base names to drop entirely + excludes map[string]map[string]bool // dimension/benchmark -> excluded values +} + +// reportOptionsFromConfig builds the report filters from the sweep flags. +func reportOptionsFromConfig(cfg *config) reportOptions { + opts := reportOptions{includeDegraded: cfg.includeDegraded} + if len(cfg.excludeRuns) > 0 { + opts.excludeRuns = make(map[string]bool, len(cfg.excludeRuns)) + for _, name := range cfg.excludeRuns { + opts.excludeRuns[name] = true + } + } + if len(cfg.excludeDims) > 0 { + opts.excludes = parseExcludeDims(cfg.excludeDims) + } + return opts +} + +// parseExcludeDims turns DIM=VALUE tokens into a column→values exclusion set, +// warning on tokens that are not in that form. +func parseExcludeDims(tokens []string) map[string]map[string]bool { + out := map[string]map[string]bool{} + for _, tok := range tokens { + col, val, ok := strings.Cut(tok, "=") + if !ok || col == "" || val == "" { + log.Printf("warning: ignoring -exclude %q (want DIM=VALUE)", tok) + continue + } + if out[col] == nil { + out[col] = map[string]bool{} + } + out[col][val] = true + } + return out +} + +// autoReport generates the report for a completed run directory as a +// best-effort step: the results are already collected, so a report failure is +// logged, not fatal. +func autoReport(cfg *config) { + if err := generateReport(cfg.outDir, reportOptionsFromConfig(cfg)); err != nil { + log.Printf("warning: generate report: %v", err) + } +} + +// generateReport reads a sweep output (or compact-transfer) directory, applies +// the requested filters, writes the derived CSVs and a self-contained +// report.typ under /report, and best-effort compiles it to PDF when Typst +// is installed. It returns an error only when there is no data to plot or a +// write fails; a missing Typst binary is reported, not fatal. +func generateReport(dir string, opts reportOptions) error { + log.Printf(" report: loading benchmark data from %s", displayPath(dir)) + runs, cdf, health, err := loadReportData(dir, opts) + if err != nil { + return err + } + + agg := aggregateReps(runs, opts.includeDegraded) + if len(agg) == 0 { + return fmt.Errorf("no benchmark data to plot in %s", dir) + } + // Name the repetitions that do not belong with their siblings, whatever the + // sweep's own per-node bounds made of them, so a figure's error bands are + // never quietly built on one. + for _, note := range repOutliers(runs, repOutlierSpread) { + log.Printf(" warning: report: %s", note) + } + + out := filepath.Join(dir, reportSubdir) + if err := os.MkdirAll(out, 0o755); err != nil { + return err + } + if err := writeAggRunsCSV(filepath.Join(out, "agg.csv"), agg); err != nil { + return err + } + + var in reportInputs + + if cmpRows := pivotComparison(agg, "dual"); cmpRows != nil { + if err := writeComparisonCSV(filepath.Join(out, "comparison.csv"), cmpRows); err != nil { + return err + } + // The side-by-side table is always useful; which ratio figures the rows + // can draw is decided per metric and per x-dimension in planFigures. + in.comparison = cmpRows + } + if tl := tlCurveRows(agg, tlLoadDimensions(dimCounts(agg))); len(tl) > 0 { + if err := writeTLCurveCSV(filepath.Join(out, "tl_curve.csv"), tl); err != nil { + return err + } + } + // Run labels name only what varies between the sweep's configurations; what + // they all share sits in the report's experiment line instead. + varying := varyingDimensions(aggConfigs(agg)) + if len(cdf) > 0 { + if err := writePlotNodeCDFCSV(filepath.Join(out, "node_cdf.csv"), cdf); err != nil { + return err + } + if nh := nodeHealthRows(health); len(nh) > 0 { + if err := writeNodeHealthCSV(filepath.Join(out, "node_health.csv"), nh); err != nil { + return err + } + in.nodeHealth = true + } + in.cdfRuns = cdfRuns(cdf, varying) + } + // Time series are selected independently of the latency CDF: an event stream + // can carry throughput intervals with no latency samples at all. + manifests, err := loadRunManifests(dir) + if err != nil { + log.Printf(" warning: report: %v", err) + } + in.timeSeries = writeTimeSeriesFigures(dir, out, manifests, cdfBases(in.cdfRuns), varying, maxTimeSeriesRuns) + in.failedTimeSeries = writeFailedTimeSeriesFigures(dir, out, manifests, varying, maxFailedRuns) + if dg := degradedShareRows(runs); slices.ContainsFunc(dg, func(r degradedShareRecord) bool { return r.degraded > 0 }) { + if err := writeDegradedShareCSV(filepath.Join(out, "degraded_share.csv"), dg); err != nil { + return err + } + in.degradedShare = true + } + // Run-status accounting and clock-offset diagnostics describe the whole + // sweep directory (every attempted run, the cluster's clock skew), so they + // intentionally ignore the per-configuration --exclude filters. + if st, err := runStatusRows(dir); err == nil && anyDegradedOrFailed(st) { + if err := writeRunStatusCSV(filepath.Join(out, "run_status.csv"), st); err != nil { + return err + } + in.runStatus = true + } + if off, err := collectOffsets(filepath.Join(dir, logSubdir)); err == nil && len(off) > 0 { + if err := writeOffsetsCSV(filepath.Join(out, "offsets.csv"), offsetCDFRows(off, offsetCDFPoints)); err != nil { + return err + } + in.offsets = true + } + + header := reportHeader{ + title: cmp.Or(opts.title, "Gorums benchmark report"), + experiment: experimentSummary(agg, sweepSettingsFromManifests(manifests, dir)), + } + specs := planFigures(agg, in) + typPath := filepath.Join(out, "report.typ") + if err := writeReportTyp(typPath, header, specs); err != nil { + return err + } + if err := copyReportLib(out); err != nil { + return err + } + artifact := compileReport(typPath) + log.Printf(" report: %d figure(s) -> %s", len(specs), displayPath(artifact)) + return nil +} + +// loadReportData reads the per-rep run records and per-node CDF records for a +// directory. It prefers the compact plotdata.binpb (the normal data present +// after a driver run's compact transfer), falls back to the legacy compact +// plotdata CSVs for output directories collected before plotdata.binpb +// existed, and falls back further to decoding the raw binary result files +// when neither is present (a local run). +func loadReportData(dir string, opts reportOptions) (runs []plotRunRecord, cdf, health []plotNodeCDFRecord, err error) { + if _, statErr := os.Stat(filepath.Join(dir, plotDataDir, plotDataFile)); statErr == nil { + pd, err := readPlotData(dir) + if err != nil { + return nil, nil, nil, err + } + allRuns, allCDF := plotRecordsFromMessage(pd) + runs = filterRuns(allRuns, opts) + cdf, health = reduceReportCDF(allCDF, opts, maxCDFRuns) + return runs, cdf, health, nil + } + + runsCSV := filepath.Join(dir, plotDataDir, "runs.csv") + if _, err := os.Stat(runsCSV); err != nil { + var allCDF []plotNodeCDFRecord + // The event streams collected here are not retained: the report renders + // time series from the raw result files still present in a local sweep + // directory, for the few runs it plans a figure for. + runs, allCDF, _, err = collectPlotData(dir) + if err != nil { + return nil, nil, nil, err + } + runs = filterRuns(runs, opts) + cdf, health = reduceReportCDF(allCDF, opts, maxCDFRuns) + return runs, cdf, health, nil + } + runs, err = readPlotRunsCSV(runsCSV) + if err != nil { + return nil, nil, nil, err + } + runs = filterRuns(runs, opts) + cdfCSV := filepath.Join(dir, plotDataDir, "node_cdf.csv") + if _, err := os.Stat(cdfCSV); err == nil { + if cdf, health, err = readReportNodeCDFCSV(cdfCSV, opts, maxCDFRuns); err != nil { + return nil, nil, nil, err + } + } + return runs, cdf, health, nil +} + +// aggConfigs returns the configurations behind the rep-averaged records. +func aggConfigs(agg []aggRunRecord) []benchkit.Dimensions { + configs := make([]benchkit.Dimensions, len(agg)) + for i, r := range agg { + configs[i] = r.Dimensions + } + return configs +} + +// cdfRuns returns the runs present in the CDF rows, in first-seen order, each +// with the compact configuration label its panel carries. The rows were already +// reduced to the selected runs upstream (see reportCDFReducer), which is where +// the panel budget is spent. +func cdfRuns(cdf []plotNodeCDFRecord, varying map[string]bool) []cdfRun { + var runs []cdfRun + seen := map[string]bool{} + for _, r := range cdf { + if seen[r.base] { + continue + } + seen[r.base] = true + runs = append(runs, cdfRun{base: r.base, title: cmp.Or(configLabel(r.Dimensions, varying), r.base)}) + } + return runs +} + +// cdfBases returns the run bases of the CDF panels, in panel order. +func cdfBases(runs []cdfRun) []string { + bases := make([]string, len(runs)) + for i, run := range runs { + bases[i] = run.base + } + return bases +} + +// sweepSettings are the sweep-wide facts a report's experiment line states. +// Every run of one sweep shares them, so they come from any one manifest; the +// run count is over all of them, whatever each run's outcome. +type sweepSettings struct { + label string + duration string + trim string + runs int +} + +// sweepSettingsFromManifests reads the sweep-wide settings from the run +// manifests, falling back to the output directory's own name for the label so a +// directory whose manifests are absent or unlabeled still names its experiment. +func sweepSettingsFromManifests(manifests []loadedRunManifest, dir string) sweepSettings { + settings := sweepSettings{runs: len(manifests)} + if len(manifests) > 0 { + m := manifests[0].manifest + settings.label, settings.duration, settings.trim = m.Label, m.Duration, m.Trim + } + settings.label = cmp.Or(settings.label, filepath.Base(strings.TrimRight(dir, "/"))) + return settings +} + +// writeTimeSeriesFigures generates throughput/latency-over-time CSVs for at +// most limit runs, one per swept configuration, and returns which benchmarks +// got data, for planFigures. dir is the report's source directory (a sweep +// output or compact-transfer directory) and out is the report's own output +// directory (/report). varying names the dimensions a run's compact label +// must state to identify it. +// +// Candidates come from the run manifests and the event data itself, not from the +// per-node CDF records: per-node CDF data is a latency artifact, while an event +// stream can carry valid throughput intervals with no latency samples at all, so +// a throughput-only benchmark has a trace to draw. Each configuration +// contributes the first of its repetitions with event data, preferring the base +// in preferred (that configuration's per-node CDF base) so both figures describe +// the same run where possible. A run with no event data anywhere contributes +// nothing, which is expected rather than an error: a sweep measured with +// interval reporting off records none. Any other failure (a write error) is +// logged and that run's figures are skipped without failing the report. +func writeTimeSeriesFigures(dir, out string, manifests []loadedRunManifest, preferred []string, varying map[string]bool, limit int) []timeSeriesRunFigures { + if limit <= 0 { + return nil + } + source := newTimeSeriesSource(dir) + + var figures []timeSeriesRunFigures + var prevHosts []string + for _, bases := range timeSeriesCandidates(manifests, preferred) { + if len(figures) >= limit { + break + } + for _, rm := range bases { + if benches := source.render(out, rm); len(benches) > 0 { + figures = append(figures, timeSeriesRunFigures{ + base: rm.base, + title: configLabel(rm.manifest.Dimensions, varying), + benches: benches, + sharesNodes: len(prevHosts) > 0 && slices.Equal(rm.manifest.Hosts, prevHosts), + }) + prevHosts = rm.manifest.Hosts + break + } + } + } + return figures +} + +// timeSeriesCandidates groups the runs whose per-node data is intact by +// configuration, so each configuration is offered as an ordered list of +// interchangeable repetitions. The configurations whose base appears in +// preferred come first, in that order, so the figure budget is spent on the same +// runs the per-node CDF grid selected rather than on whichever configurations +// sort first; within a configuration, a base in preferred is moved to the front, +// making it the repetition writeTimeSeriesFigures renders when it has event +// data. The result then alternates stream modes, so a figure cap smaller than the +// candidate list still covers both arms of a comparison. Failed runs are left +// out: they have no aggregate to sit beside, and are rendered by their own report +// section instead (see writeFailedTimeSeriesFigures). +func timeSeriesCandidates(manifests []loadedRunManifest, preferred []string) [][]loadedRunManifest { + var order []benchkit.Dimensions + byConfig := map[benchkit.Dimensions][]loadedRunManifest{} + rank := map[benchkit.Dimensions]int{} + for _, rm := range manifests { + if rm.manifest.Status != runStatusSucceeded && rm.manifest.Status != runStatusDegraded { + continue + } + config := rm.manifest.Dimensions + runs, ok := byConfig[config] + if !ok { + order = append(order, config) + rank[config] = len(preferred) + } + if i := slices.Index(preferred, rm.base); i >= 0 { + runs = slices.Insert(runs, 0, rm) + rank[config] = min(rank[config], i) + } else { + runs = append(runs, rm) + } + byConfig[config] = runs + } + slices.SortStableFunc(order, func(a, b benchkit.Dimensions) int { + return cmp.Compare(rank[a], rank[b]) + }) + candidates := make([][]loadedRunManifest, len(order)) + for i, config := range order { + candidates[i] = byConfig[config] + } + return alternateStreamModes(candidates) +} + +// alternateStreamModes reorders candidate configurations to take one stream mode +// after another, keeping each mode's own order. A comparison sweep offers far +// more configurations than a report draws figures for, and its modes group +// together in every natural order (a base name sorts them, and so does the +// dimension tuple), which spends the whole cap on one arm. A single-mode sweep is +// returned unchanged. +func alternateStreamModes(candidates [][]loadedRunManifest) [][]loadedRunManifest { + var modes []string + byMode := map[string][][]loadedRunManifest{} + for _, runs := range candidates { + mode := runs[0].manifest.StreamMode + if _, ok := byMode[mode]; !ok { + modes = append(modes, mode) + } + byMode[mode] = append(byMode[mode], runs) + } + if len(modes) < 2 { + return candidates + } + out := make([][]loadedRunManifest, 0, len(candidates)) + for i := 0; len(out) < len(candidates); i++ { + for _, mode := range modes { + if i < len(byMode[mode]) { + out = append(out, byMode[mode][i]) + } + } + } + return out +} + +// maxFailedRuns caps how many failed runs get a time-series figure. A sweep's +// failures are usually one fault repeated, so one representative per +// (configuration, error signature) group is enough to see the shape, and the cap +// keeps a badly broken sweep from filling its report with them. +const maxFailedRuns = 3 + +// failedRunGroup identifies failed runs that show the same thing: the same +// configuration failing the same way. +type failedRunGroup struct { + config benchkit.Dimensions + signature string +} + +// digits matches a run of decimal digits, which errorSignature folds away. +var digits = regexp.MustCompile(`[0-9]+`) + +// errorSignature reduces a failed run's cause to a grouping key: the failure +// phase plus its message with digit runs folded to "N", so failures differing +// only in an exit status, a port, or a node index group together. +func errorSignature(m runManifest) string { + return m.FailurePhase + "|" + digits.ReplaceAllString(m.Error, "N") +} + +// writeFailedTimeSeriesFigures renders the time series of failed runs, which +// have no aggregate row and so no place among the per-configuration figures. A +// failed run's throughput trace is the most informative view it has: it shows +// whether its nodes were producing work at all, and when they stopped. +// +// Failed runs are grouped by configuration and error signature and one +// representative of each group is rendered, up to limit figures; whatever the +// cap drops is logged, so the report's failed-runs section never reads as +// complete when it is not. A group whose representative has no event data lets +// the next run in the group stand in. +func writeFailedTimeSeriesFigures(dir, out string, manifests []loadedRunManifest, varying map[string]bool, limit int) []timeSeriesRunFigures { + if limit <= 0 { + return nil + } + groupSize := map[failedRunGroup]int{} + for _, rm := range manifests { + if rm.manifest.Status == runStatusFailed { + groupSize[failedRunGroup{rm.manifest.Dimensions, errorSignature(rm.manifest)}]++ + } + } + + source := newTimeSeriesSource(dir) + rendered := map[failedRunGroup]bool{} + var figures []timeSeriesRunFigures + var dropped int + for _, rm := range manifests { + if rm.manifest.Status != runStatusFailed { + continue + } + group := failedRunGroup{rm.manifest.Dimensions, errorSignature(rm.manifest)} + if rendered[group] { + continue + } + if len(figures) >= limit { + rendered[group] = true // count each dropped group once + dropped++ + continue + } + benches := source.render(out, rm) + if len(benches) == 0 { + continue // no event data for this run; try another of its group + } + rendered[group] = true + figures = append(figures, timeSeriesRunFigures{ + base: rm.base, + title: configLabel(rm.manifest.Dimensions, varying), + benches: benches, + note: failedRunNote(rm.manifest, groupSize[group]), + }) + } + if dropped > 0 { + log.Printf(" report: %d further failed-run group(s) not shown (cap %d figures)", dropped, limit) + } + return figures +} + +// failedRunNote is the caveat printed under a failed run's figures: how the run +// failed, how many nodes are missing from the figure entirely, and how many +// other failed runs this one stands for. +func failedRunNote(m runManifest, groupSize int) string { + note := fmt.Sprintf("Run failed during %s: %s.", cmp.Or(m.FailurePhase, "the run"), oneLine(m.Error)) + if missing := len(m.MissingFiles); missing > 0 { + note += fmt.Sprintf(" %d of %d nodes wrote no result file and contribute no line at all, so an absent trace is an absent node, not an idle one.", + missing, len(m.Files)) + } + if groupSize > 1 { + note += fmt.Sprintf(" Representative of %d failed runs with this configuration and error.", groupSize) + } + return note +} + +// timeSeriesSource renders a run's event streams from whichever form the +// directory holds: the exported plotdata/events.binpb, which a compact transfer +// carries for every run, or the raw per-node result files a sweep output +// directory still has. The exported streams are preferred, since they are +// present for runs whose raw files were left on the driver. +type timeSeriesSource struct { + dir string + events map[string]*benchkit.PlotRunEvents // run base -> its streams; nil when the directory has none +} + +// newTimeSeriesSource reads the exported event streams for dir, best effort: an +// unreadable events.binpb is reported and leaves the raw result files as the +// only source. +func newTimeSeriesSource(dir string) *timeSeriesSource { + source := &timeSeriesSource{dir: dir} + events, err := readPlotEvents(dir) + if err != nil { + log.Printf(" warning: time-series: read %s: %v", plotEventsFile, err) + return source + } + if runs := events.GetRuns(); len(runs) > 0 { + source.events = make(map[string]*benchkit.PlotRunEvents, len(runs)) + for _, run := range runs { + source.events[run.GetBase()] = run + } + } + return source +} + +// render writes one run's time-series CSVs under /timeseries/ and +// returns the benchmarks that got data, or nil when the run has no event data +// or could not be rendered. +func (s *timeSeriesSource) render(out string, rm loadedRunManifest) []string { + trim, err := parseManifestTrim(rm.manifest.Trim) + if err != nil { + log.Printf(" warning: time-series: %s trim %q: %v", rm.base, rm.manifest.Trim, err) + trim = 0 + } + outDir := filepath.Join(out, "timeseries", rm.base) + var benches []string + if runEvents, ok := s.events[rm.base]; ok { + benches, err = eventTimeSeries(runEvents, outDir, trim) + } else { + files := make([]string, len(rm.manifest.Files)) + for i, f := range rm.manifest.Files { + files[i] = filepath.Join(s.dir, f) + } + benches, err = generateTimeSeries(files, outDir, nil, trim) + } + if err != nil { + log.Printf(" warning: time-series: %s: %v", rm.base, err) + return nil + } + return benches +} + +// filterRuns drops runs excluded by base name or by a dimension/benchmark value. +func filterRuns(runs []plotRunRecord, opts reportOptions) []plotRunRecord { + return slices.DeleteFunc(slices.Clone(runs), func(r plotRunRecord) bool { + return opts.excludeRuns[r.base] || excludedByDim(opts.excludes, r.Dimensions) + }) +} + +// excludedByDim reports whether a record matches any --exclude filter. It +// iterates the (small) filter set rather than building a per-record column map. +func excludedByDim(ex map[string]map[string]bool, dims benchkit.Dimensions) bool { + for col, vals := range ex { + dim, ok := findDimension(col) + if !ok { + continue + } + if vals[dim.value(dims)] { + return true + } + } + return false +} + +// compileReport compiles report.typ to a sibling PDF when the typst binary is +// available, otherwise prints the command to run by hand. It returns the path +// a reader should look at: the compiled PDF on success, or report.typ itself +// when typst is unavailable or compilation fails. +func compileReport(typPath string) string { + if _, err := exec.LookPath("typst"); err != nil { + log.Printf(" report: typst not found; compile with: typst compile %q", typPath) + return typPath + } + pdf := strings.TrimSuffix(filepath.Base(typPath), ".typ") + ".pdf" + cmd := exec.Command("typst", "compile", filepath.Base(typPath), pdf) + cmd.Dir = filepath.Dir(typPath) + log.Printf(" report: compiling %s...", displayPath(typPath)) + if outBytes, err := cmd.CombinedOutput(); err != nil { + log.Printf(" report: typst compile failed: %v\n%s", err, outBytes) + return typPath + } + return filepath.Join(filepath.Dir(typPath), pdf) +} + +// ── plotdata CSV readers ───────────────────────────────────────────────────── + +// readPlotRunsCSV parses the compact per-rep runs.csv back into plotRunRecords. +// Columns are addressed by header name, so extra or reordered columns are +// tolerated; absent latency fields decode to nil pointers. +func readPlotRunsCSV(path string) ([]plotRunRecord, error) { + var out []plotRunRecord + err := forEachCSVRow(path, false, func(r []string, col map[string]int) error { + out = append(out, plotRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: field(r, col, "benchmark"), + Nodes: atoiOr(field(r, col, "nodes"), 0), + Workers: atoiOr(field(r, col, "workers"), 0), + Payload: atoiOr(field(r, col, "payload"), 0), + Rate: atoiOr(field(r, col, "rate"), 0), + SendBuffer: atoiOr(field(r, col, "send_buffer"), 0), + RecvBuffer: atoiOr(field(r, col, "recv_buffer"), 0), + StreamMode: field(r, col, "stream_mode"), + }, + base: field(r, col, "base"), + label: field(r, col, "label"), + status: field(r, col, "status"), + rep: atoiOr(field(r, col, "rep"), 1), + throughput: atofOr(field(r, col, "throughput"), 0), + totalOps: atouOr(field(r, col, "total_ops"), 0), + failedOps: atouOr(field(r, col, "failed_ops"), 0), + allocsPerOp: atofOr(field(r, col, "allocs_per_op"), 0), + memPerOp: atofOr(field(r, col, "mem_per_op"), 0), + nodesSeen: atoiOr(field(r, col, "nodes_seen"), 0), + meanUS: floatPtr(field(r, col, "mean_us")), + p50US: floatPtr(field(r, col, "p50_us")), + p95US: floatPtr(field(r, col, "p95_us")), + p99US: floatPtr(field(r, col, "p99_us")), + samples: uintPtr(field(r, col, "samples")), + }) + return nil + }) + return out, err +} + +// readReportNodeCDFCSV streams the compact per-node data, retaining one row +// per node for the health heatmap and full CDF points for at most limit runs. +// This bounds report memory and output size even when the source contains +// millions of CDF rows. +func readReportNodeCDFCSV(path string, opts reportOptions, limit int) ([]plotNodeCDFRecord, []plotNodeCDFRecord, error) { + reducer := newReportCDFReducer(opts, limit) + err := forEachCSVRow(path, true, func(row []string, col map[string]int) error { + base := field(row, col, "base") + dims := benchkit.Dimensions{ + Benchmark: field(row, col, "benchmark"), + Nodes: atoiOr(field(row, col, "nodes"), 0), + Workers: atoiOr(field(row, col, "workers"), 0), + Payload: atoiOr(field(row, col, "payload"), 0), + Rate: atoiOr(field(row, col, "rate"), 0), + SendBuffer: atoiOr(field(row, col, "send_buffer"), 0), + RecvBuffer: atoiOr(field(row, col, "recv_buffer"), 0), + StreamMode: field(row, col, "stream_mode"), + } + if reducer.excluded(base, dims) { + return nil + } + + node := field(row, col, "node") + keepCDF, keepHealth := reducer.selectRows( + base, dims, node, + ) + if !keepCDF && !keepHealth { + return nil + } + record := plotNodeCDFRecord{ + Dimensions: dims, + base: base, + label: field(row, col, "label"), + status: field(row, col, "status"), + rep: atoiOr(field(row, col, "rep"), 1), + node: node, + throughput: atofOr(field(row, col, "throughput"), 0), + meanUS: atofOr(field(row, col, "mean_us"), 0), + p50US: atofOr(field(row, col, "p50_us"), 0), + p95US: atofOr(field(row, col, "p95_us"), 0), + p99US: atofOr(field(row, col, "p99_us"), 0), + samples: atouOr(field(row, col, "samples"), 0), + prob: atofOr(field(row, col, "prob"), 0), + cdfUS: atofOr(field(row, col, "cdf_us"), 0), + } + reducer.add(record, keepCDF, keepHealth) + return nil + }) + reducer.finish() + return reducer.cdf, reducer.health, err +} + +// reportCDFReducer keeps the full CDF of the first repetition of selected +// configurations, up to limit runs, plus one health row per node of every +// eligible run. A configuration is the whole dimension tuple, buffer capacities +// included, so each arm of a buffer sweep contributes its own CDF. +// +// Selection spreads the panels across the sweep: a configuration is taken when it +// is the first to measure some dimension value — a node count, payload, offered +// rate, or stream mode no taken configuration used yet — so the panels do not +// crowd into whichever corner of the sweep sorts first. Since that rule alone +// stops as soon as every value is covered, the remaining panels are filled from +// the configurations it passed over, in run order. Both lists are built in one +// streaming pass, and [reportCDFReducer.finish] drops the rows of the runs that +// did not make the budget, so a source with millions of CDF rows is never held in +// memory. +type reportCDFReducer struct { + opts reportOptions + limit int + valuesSeen map[string]bool + configsSeen map[benchkit.Dimensions]bool + spread []string // bases taken for a dimension value no earlier one took + filler []string // bases held to fill the panel budget the spread leaves + retained map[string]bool + healthSeen map[struct{ base, benchmark, node string }]bool + cdf []plotNodeCDFRecord + health []plotNodeCDFRecord +} + +func newReportCDFReducer(opts reportOptions, limit int) *reportCDFReducer { + return &reportCDFReducer{ + opts: opts, + limit: limit, + valuesSeen: make(map[string]bool), + configsSeen: make(map[benchkit.Dimensions]bool), + retained: make(map[string]bool), + healthSeen: make(map[struct{ base, benchmark, node string }]bool), + } +} + +func (r *reportCDFReducer) excluded(base string, dims benchkit.Dimensions) bool { + return r.opts.excludeRuns[base] || excludedByDim(r.opts.excludes, dims) +} + +func (r *reportCDFReducer) selectRows(base string, dims benchkit.Dimensions, node string) (keepCDF, keepHealth bool) { + if !r.configsSeen[dims] { + r.configsSeen[dims] = true + switch { + case r.spreads(dims): + if len(r.spread) < r.limit { + r.spread = append(r.spread, base) + r.retained[base] = true + } + case len(r.filler) < r.limit: + r.filler = append(r.filler, base) + r.retained[base] = true + } + } + key := struct{ base, benchmark, node string }{base, dims.Benchmark, node} + if !r.healthSeen[key] { + r.healthSeen[key] = true + keepHealth = true + } + return r.retained[base], keepHealth +} + +// spreads reports whether a configuration takes a dimension value no already +// taken configuration took, recording its values either way. +func (r *reportCDFReducer) spreads(dims benchkit.Dimensions) bool { + novel := false + for _, dim := range dimensionSpecs { + key := dim.name + "=" + dim.value(dims) + if !r.valuesSeen[key] { + r.valuesSeen[key] = true + novel = true + } + } + return novel +} + +// finish drops the retained rows of the runs the panel budget cannot hold: the +// spread runs come first, and the fillers take what budget is left. Rows keep +// their original order, so the panels follow run order rather than the order the +// two lists were built in. +func (r *reportCDFReducer) finish() { + chosen := make(map[string]bool, r.limit) + for _, base := range r.spread { + chosen[base] = true + } + for _, base := range r.filler { + if len(chosen) >= r.limit { + break + } + chosen[base] = true + } + r.cdf = slices.DeleteFunc(r.cdf, func(row plotNodeCDFRecord) bool { return !chosen[row.base] }) +} + +func (r *reportCDFReducer) add(record plotNodeCDFRecord, keepCDF, keepHealth bool) { + if keepCDF { + r.cdf = append(r.cdf, record) + } + if keepHealth { + r.health = append(r.health, record) + } +} + +func reduceReportCDF(rows []plotNodeCDFRecord, opts reportOptions, limit int) ([]plotNodeCDFRecord, []plotNodeCDFRecord) { + reducer := newReportCDFReducer(opts, limit) + for _, record := range rows { + if reducer.excluded(record.base, record.Dimensions) { + continue + } + keepCDF, keepHealth := reducer.selectRows(record.base, record.Dimensions, record.node) + reducer.add(record, keepCDF, keepHealth) + } + reducer.finish() + return reducer.cdf, reducer.health +} + +// columnIndex maps each header name to its column position. +func columnIndex(header []string) map[string]int { + idx := make(map[string]int, len(header)) + for i, name := range header { + idx[name] = i + } + return idx +} + +// field returns the value of the named column, or "" when the column is absent +// or the row is short. +func field(row []string, col map[string]int, name string) string { + i, ok := col[name] + if !ok || i >= len(row) { + return "" + } + return row[i] +} + +func atoiOr(s string, def int) int { + if v, err := strconv.Atoi(s); err == nil { + return v + } + return def +} + +func atofOr(s string, def float64) float64 { + if v, err := strconv.ParseFloat(s, 64); err == nil { + return v + } + return def +} + +func atouOr(s string, def uint64) uint64 { + if v, err := strconv.ParseUint(s, 10, 64); err == nil { + return v + } + return def +} + +// floatPtr parses s into a *float64, returning nil for an empty field so an +// absent metric round-trips as nil rather than a spurious zero. +func floatPtr(s string) *float64 { + if s == "" { + return nil + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return nil + } + return &v +} + +func uintPtr(s string) *uint64 { + if s == "" { + return nil + } + v, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return nil + } + return &v +} diff --git a/benchkit/cmd/sweep/plotmode_test.go b/benchkit/cmd/sweep/plotmode_test.go new file mode 100644 index 00000000..79c8f616 --- /dev/null +++ b/benchkit/cmd/sweep/plotmode_test.go @@ -0,0 +1,853 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "github.com/relab/gorums/benchkit" + "google.golang.org/protobuf/proto" +) + +// reportFixtureData builds the run and per-node CDF records shared by every +// report input test: two stream modes, a varying worker count, a degraded +// rep, and one run's per-node CDF for the health + CDF figures. +func reportFixtureData() ([]plotRunRecord, []plotNodeCDFRecord) { + var runs []plotRunRecord + var cdf []plotNodeCDFRecord + for _, mode := range []string{"dual", "dedup"} { + for _, w := range []int{2, 4, 8} { + for rep := 1; rep <= 2; rep++ { + lat := 400 + float64(w*40) + base := "run_Q_N3_W" + strconv.Itoa(w) + "_S" + mode + "_r" + strconv.Itoa(rep) + status := runStatusSucceeded + if mode == "dual" && w == 8 && rep == 2 { + status = runStatusDegraded // one degraded rep to populate the share/status figures + } + runs = append(runs, plotRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: w, Payload: 1024, StreamMode: mode, + }, + base: base, label: "run", status: status, rep: rep, + throughput: float64(w) * 5000, allocsPerOp: 12, memPerOp: 2048, + meanUS: new(lat), p50US: new(lat), p95US: new(lat * 1.5), p99US: new(lat * 2), + samples: new(uint64(1000)), + }) + // One run's per-node CDF, for the health + CDF figures. + if w == 8 && rep == 1 { + for _, node := range []string{"bb1:9000", "bb2:9000"} { + for i := 0; i <= 10; i++ { + prob := float64(i) / 10 + cdf = append(cdf, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: w, Payload: 1024, StreamMode: mode, + }, + base: base, label: "run", status: status, rep: rep, + node: node, throughput: float64(w) * 5000, prob: prob, cdfUS: lat + 800*prob, + }) + } + } + } + } + } + } + return runs, cdf +} + +// reportFixtureManifestsAndLogs writes the manifests and run log shared by +// every report input test: a manifest per run status so runStatusRows sees a +// degraded outcome, a failed one that carries no plotdata row, and a run log +// with a cross-machine offset line for the offset CDF. +func reportFixtureManifestsAndLogs(t *testing.T, dir string) { + t.Helper() + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, "m1", runStatusSucceeded, 1, "", []string{resultFilename("m1", n, resultExt)}) + writePlotManifest(t, dir, "m2", runStatusDegraded, 2, "", []string{resultFilename("m2", n, resultExt)}) + writePlotManifest(t, dir, "m3", runStatusFailed, 3, "", []string{resultFilename("m3", n, resultExt)}) + + logs := filepath.Join(dir, logSubdir) + if err := os.MkdirAll(logs, 0o755); err != nil { + t.Fatal(err) + } + logLine := "[offsets node 3 (10.0.0.1:9000)] peer 1: before=-120µs after=-118µs drift=-2µs\n" + if err := os.WriteFile(filepath.Join(logs, "run_Q_N3.log"), []byte(logLine), 0o644); err != nil { + t.Fatal(err) + } +} + +// buildReportDir writes a compact plotdata directory using the legacy CSV +// pair (runs.csv, node_cdf.csv), exercising the fallback report input path +// for sweep output directories collected before plotdata.binpb existed. +func buildReportDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + plotdataDir := filepath.Join(dir, plotDataDir) + if err := os.MkdirAll(plotdataDir, 0o755); err != nil { + t.Fatal(err) + } + runs, cdf := reportFixtureData() + if err := writePlotRunsCSV(filepath.Join(plotdataDir, "runs.csv"), runs); err != nil { + t.Fatal(err) + } + if err := writePlotNodeCDFCSV(filepath.Join(plotdataDir, "node_cdf.csv"), cdf); err != nil { + t.Fatal(err) + } + reportFixtureManifestsAndLogs(t, dir) + return dir +} + +// buildReportDirBinpb writes the same fixture data as buildReportDir, but as +// the normalized plotdata.binpb a collected sweep directory now contains, +// exercising the primary (non-legacy) report input path. +func buildReportDirBinpb(t *testing.T) string { + t.Helper() + dir := t.TempDir() + plotdataDir := filepath.Join(dir, plotDataDir) + if err := os.MkdirAll(plotdataDir, 0o755); err != nil { + t.Fatal(err) + } + runs, cdf := reportFixtureData() + data, err := proto.Marshal(buildPlotData(runs, cdf)) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(plotdataDir, plotDataFile), data, 0o644); err != nil { + t.Fatal(err) + } + reportFixtureManifestsAndLogs(t, dir) + return dir +} + +// reportManifests loads a fixture directory's run manifests, for tests that +// drive the report pipeline's manifest-fed steps directly instead of through +// generateReport. +func reportManifests(t *testing.T, dir string) []loadedRunManifest { + t.Helper() + manifests, err := loadRunManifests(dir) + if err != nil { + t.Fatal(err) + } + return manifests +} + +func TestGenerateReport(t *testing.T) { + dir := buildReportDir(t) + if err := generateReport(dir, reportOptions{title: "Test"}); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, reportSubdir) + for _, name := range []string{ + "agg.csv", "comparison.csv", "tl_curve.csv", "node_cdf.csv", + "node_health.csv", "degraded_share.csv", "run_status.csv", "offsets.csv", + "report.typ", reportLibName, + } { + if _, err := os.Stat(filepath.Join(out, name)); err != nil { + t.Errorf("missing %s: %v", name, err) + } + } + + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping compile check") + } + cmd := exec.Command("typst", "compile", "report.typ", "report.pdf") + cmd.Dir = out + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile failed: %v\n%s", err, b) + } +} + +// TestGenerateReportFromBinpb verifies that a report generated from the +// normalized plotdata.binpb produces the same figures and derived CSVs as one +// generated from the legacy runs.csv/node_cdf.csv pair. +func TestGenerateReportFromBinpb(t *testing.T) { + dir := buildReportDirBinpb(t) + if err := generateReport(dir, reportOptions{title: "Test"}); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, reportSubdir) + for _, name := range []string{ + "agg.csv", "comparison.csv", "tl_curve.csv", "node_cdf.csv", + "node_health.csv", "degraded_share.csv", "run_status.csv", "offsets.csv", + "report.typ", reportLibName, + } { + if _, err := os.Stat(filepath.Join(out, name)); err != nil { + t.Errorf("missing %s: %v", name, err) + } + } +} + +func TestGenerateReportExcludeRun(t *testing.T) { + dir := buildReportDir(t) + runs, _, _, err := loadReportData(dir, reportOptions{}) + if err != nil { + t.Fatal(err) + } + // Exclude every dual run by base; only dedup rows should remain. + opts := reportOptions{excludeRuns: map[string]bool{}} + for _, r := range runs { + if r.StreamMode == "dual" { + opts.excludeRuns[r.base] = true + } + } + kept := filterRuns(runs, opts) + for _, r := range kept { + if r.StreamMode == "dual" { + t.Fatalf("dual run %s survived exclude-run", r.base) + } + } + if len(kept) == 0 { + t.Fatal("all runs excluded") + } +} + +func TestFilterRunsExcludeDim(t *testing.T) { + runs := []plotRunRecord{ + {base: "a", Dimensions: benchkit.Dimensions{Benchmark: "Q", StreamMode: "dual", Nodes: 3, Workers: 8, Payload: 1024}}, + {base: "b", Dimensions: benchkit.Dimensions{Benchmark: "Q", StreamMode: "dual", Nodes: 3, Workers: 2, Payload: 1024}}, + } + opts := reportOptions{excludes: map[string]map[string]bool{"workers": {"8": true}}} + kept := filterRuns(runs, opts) + if len(kept) != 1 || kept[0].Workers != 2 { + t.Errorf("kept = %+v, want only workers=2", kept) + } +} + +func TestReadPlotRunsCSVRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "runs.csv") + want := []plotRunRecord{{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: 8, Payload: 1024, StreamMode: "dedup", + }, + base: "run_Q", label: "run", status: runStatusSucceeded, rep: 1, + throughput: 40000, allocsPerOp: 12, memPerOp: 2048, nodesSeen: 3, + meanUS: new(500.0), p50US: new(450.0), p95US: new(900.0), p99US: new(1200.0), + }} + if err := writePlotRunsCSV(path, want); err != nil { + t.Fatal(err) + } + got, err := readPlotRunsCSV(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("rows = %d, want 1", len(got)) + } + g := got[0] + if g.base != "run_Q" || g.Workers != 8 || g.StreamMode != "dedup" || g.throughput != 40000 { + t.Errorf("round-trip mismatch: %+v", g) + } + if g.p50US == nil || *g.p50US != 450 { + t.Errorf("p50US = %v, want 450", g.p50US) + } +} + +func TestReadReportNodeCDFCSVSelectsRunsAndReducesHealth(t *testing.T) { + path := filepath.Join(t.TempDir(), "node_cdf.csv") + var rows []plotNodeCDFRecord + for base := range 8 { + for _, node := range []string{"bb1:9000", "bb2:9000"} { + for point := range 3 { + rows = append(rows, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 2, Workers: base/2 + 1, StreamMode: "dual", + }, + base: "run-" + strconv.Itoa(base), label: "run", + status: runStatusSucceeded, rep: 1, + node: node, throughput: float64(100 + base), + prob: float64(point) / 2, cdfUS: float64(100 + point), + }) + } + } + } + if err := writePlotNodeCDFCSV(path, rows); err != nil { + t.Fatal(err) + } + + opts := reportOptions{excludeRuns: map[string]bool{"run-0": true}} + cdf, health, err := readReportNodeCDFCSV(path, opts, 3) + if err != nil { + t.Fatal(err) + } + if got, want := cdfBases(cdfRuns(cdf, nil)), []string{"run-1", "run-2", "run-4"}; !slices.Equal(got, want) { + t.Errorf("CDF bases = %v, want %v", got, want) + } + if len(cdf) != 3*2*3 { + t.Errorf("CDF rows = %d, want %d", len(cdf), 3*2*3) + } + // Health retains one row per node for every eligible run, including runs + // whose full CDF was not selected for the report. + if len(health) != 7*2 { + t.Errorf("health rows = %d, want %d", len(health), 7*2) + } +} + +// TestReduceReportCDFDistinguishesBufferArms verifies that two runs differing +// only by a buffer capacity count as two configurations, so each arm of a +// buffer sweep contributes its own per-node CDF instead of the arms merging +// into a single sample. +func TestReduceReportCDFDistinguishesBufferArms(t *testing.T) { + var rows []plotNodeCDFRecord + for _, sendBuffer := range []int{64, 256} { + for point := range 3 { + rows = append(rows, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: 8, SendBuffer: sendBuffer, StreamMode: "dual", + }, + base: "run-S" + strconv.Itoa(sendBuffer), label: "run", + status: runStatusSucceeded, rep: 1, + node: "bb1:9000", throughput: 5000, + prob: float64(point) / 2, cdfUS: float64(100 + point), + }) + } + } + cdf, _ := reduceReportCDF(rows, reportOptions{}, 2) + if got, want := cdfBases(cdfRuns(cdf, nil)), []string{"run-S64", "run-S256"}; !slices.Equal(got, want) { + t.Errorf("CDF bases = %v, want %v", got, want) + } +} + +// TestReduceReportCDFSelectsPanelRuns verifies the CDF panel selection on a 3x3 +// node-count/payload grid whose base order walks the payloads of N=3 first, so a +// first-seen rule would spend a small budget entirely on N=3. A budget smaller +// than the grid must still cover every value each dimension took, and a budget +// larger than the runs that requires must be filled rather than left short. +func TestReduceReportCDFSelectsPanelRuns(t *testing.T) { + var rows []plotNodeCDFRecord + for _, nodes := range []int{3, 9, 27} { + for _, payload := range []int{0, 1024, 16384} { + base := fmt.Sprintf("run_N%d_P%d", nodes, payload) + for point := range 3 { + rows = append(rows, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: nodes, Workers: 8, Payload: payload, StreamMode: "dual", + }, + base: base, label: "run", status: runStatusSucceeded, rep: 1, + node: "bb1:9000", throughput: 5000, + prob: float64(point) / 2, cdfUS: float64(100 + point), + }) + } + } + } + varying := varyingDimensions([]benchkit.Dimensions{ + {Nodes: 3, Payload: 0}, {Nodes: 9, Payload: 1024}, + }) + + t.Run("SpreadsUnderABudget", func(t *testing.T) { + cdf, _ := reduceReportCDF(rows, reportOptions{}, 5) + runs := cdfRuns(cdf, varying) + nodesSeen, payloadsSeen := map[int]bool{}, map[int]bool{} + for _, r := range cdf { + nodesSeen[r.Nodes] = true + payloadsSeen[r.Payload] = true + } + if len(nodesSeen) != 3 || len(payloadsSeen) != 3 { + t.Errorf("5 panels cover %d node count(s) and %d payload(s), want 3 and 3; panels = %v", + len(nodesSeen), len(payloadsSeen), cdfRunTitles(runs)) + } + // Each panel is titled by its configuration, not by the run base. + for _, run := range runs { + if run.title == run.base { + t.Errorf("run %s has no configuration label", run.base) + } + } + }) + + t.Run("FillsTheBudget", func(t *testing.T) { + // Five configurations introduce a new dimension value; the other four + // must fill the remaining panels rather than leaving the page short. + cdf, _ := reduceReportCDF(rows, reportOptions{}, 8) + if got := len(cdfRuns(cdf, varying)); got != 8 { + t.Errorf("panels = %d, want 8 of the 9 configurations", got) + } + if got := len(cdfRuns(mustReduce(rows, 9), varying)); got != 9 { + t.Errorf("panels = %d, want all 9 configurations", got) + } + }) +} + +func mustReduce(rows []plotNodeCDFRecord, limit int) []plotNodeCDFRecord { + cdf, _ := reduceReportCDF(rows, reportOptions{}, limit) + return cdf +} + +func cdfRunTitles(runs []cdfRun) []string { + titles := make([]string, len(runs)) + for i, run := range runs { + titles[i] = run.title + } + return titles +} + +// TestWriteTimeSeriesFiguresAlternatesStreamModes verifies that the over-time +// figures cover both arms of a comparison: every natural order groups the modes +// together, so a cap smaller than the candidate list used to spend itself on one +// mode. It also verifies that a run over the same nodes as the previous figure +// shares its legend instead of repeating it. +func TestWriteTimeSeriesFiguresAlternatesStreamModes(t *testing.T) { + dir := t.TempDir() + n := nodeAssignment{host: "bb1", port: 9000} + var preferred []string + for _, mode := range []string{"dedup", "dual"} { + for _, workers := range []int32{1, 2, 4} { + base := fmt.Sprintf("e1_Q_N1_W%d_P0_S%s_r1", workers, mode) + preferred = append(preferred, base) + writePlotManifestDims(t, dir, base, runStatusSucceeded, 1, "", benchkit.Dimensions{ + Benchmark: "Q", Nodes: 1, Workers: int(workers), StreamMode: mode, + }, []string{n.hostAddr()}, []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfigWithStreamMode("Q", 1, workers, 0, 0, mode), + Events: []*benchkit.Event{tputEvent(0, 100), tputEvent(1_000_000_000, 100)}, + }.Build()) + } + } + + out := filepath.Join(dir, reportSubdir) + figures := writeTimeSeriesFigures(dir, out, reportManifests(t, dir), preferred, nil, 4) + var modes []string + for _, f := range figures { + if strings.Contains(f.base, "Sdedup") { + modes = append(modes, "dedup") + } else { + modes = append(modes, "dual") + } + } + if want := []string{"dedup", "dual", "dedup", "dual"}; !slices.Equal(modes, want) { + t.Errorf("figure modes = %v, want %v (bases %v)", modes, want, figureBases(figures)) + } + // Every run here has the same single node, so only the first figure draws a + // legend and the rest read the colors off it. + if figures[0].sharesNodes { + t.Error("the first figure must draw its own legend") + } + for _, f := range figures[1:] { + if !f.sharesNodes { + t.Errorf("%s repeats the legend of an identical node set", f.base) + } + } +} + +func figureBases(figures []timeSeriesRunFigures) []string { + bases := make([]string, len(figures)) + for i, f := range figures { + bases[i] = f.base + } + return bases +} + +// TestGenerateReportIncludesTimeSeries verifies the end-to-end wiring: a +// local sweep directory (raw per-node result files still present, matching +// what autoReport sees right after a local run) whose result carries an +// Events stream produces timeseries//_{throughput,latency, +// saturation}.csv alongside the other report CSVs, time-series and +// a time-series figure reading all three in report.typ, and a report that still +// compiles with Typst. This result's Events carry no PhaseMarker, so its +// saturation CSV has zero rows — the case the figure must guard against, since +// an empty node list would otherwise error in hlegend's grid(columns: 0). +func TestGenerateReportIncludesTimeSeries(t *testing.T) { + dir := t.TempDir() + const base = "run_Q_N1_W1_P0" + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 100, + Latencies: []int64{1000, 2000}, // gives this run per-node CDF data, so its base is selected + Events: []*benchkit.Event{ + benchkit.Event_builder{ + Offset: 0, + Throughput: benchkit.ThroughputInterval_builder{Ops: 100, Duration: 1_000_000_000}.Build(), + }.Build(), + benchkit.Event_builder{ + Offset: 0, + Latency: benchkit.LatencyInterval_builder{Mean: 1500, Stddev: 500, Count: 2}.Build(), + }.Build(), + }, + }.Build()) + + if err := generateReport(dir, reportOptions{title: "Test"}); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, reportSubdir) + + for _, name := range []string{ + filepath.Join("timeseries", base, "Q_throughput.csv"), + filepath.Join("timeseries", base, "Q_latency.csv"), + filepath.Join("timeseries", base, "Q_saturation.csv"), + } { + if _, err := os.Stat(filepath.Join(out, name)); err != nil { + t.Errorf("missing %s: %v", name, err) + } + } + + typData, err := os.ReadFile(filepath.Join(out, "report.typ")) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"time-series(csv(", "Q_saturation.csv"} { + if !strings.Contains(string(typData), want) { + t.Errorf("report.typ missing %q in its time-series figure call:\n%s", want, typData) + } + } + // The saturation curve is a panel of the time-series figure, not a section + // of its own: a run measured at one offered rate has a single point per node + // there, which the sweep's throughput-vs-rate figure already shows. + if strings.Contains(string(typData), "== #text(\"Saturation curve") { + t.Errorf("report.typ still gives the saturation curve its own section:\n%s", typData) + } + + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping compile check") + } + cmd := exec.Command("typst", "compile", "report.typ", "report.pdf") + cmd.Dir = out + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile failed: %v\n%s", err, b) + } +} + +// timeSeriesFixture writes a run directory holding every base in bases as a +// manifest of one node, with a decodable raw result file written only for the +// bases in withRaw. It returns the directory and the per-node health records +// covering all of them, in the order the report's CDF reducer produces: one +// row per base, first-seen base per configuration first. +func timeSeriesFixture(t *testing.T, bases []string, withRaw []string, streamMode string) string { + t.Helper() + dir := t.TempDir() + n := nodeAssignment{host: "bb1", port: 9000} + for _, base := range bases { + writePlotManifestWithStreamMode(t, dir, base, runStatusSucceeded, 1, "", streamMode, + []string{resultFilename(base, n, resultExt)}) + if slices.Contains(withRaw, base) { + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Events: []*benchkit.Event{tputEvent(0, 100)}, + }.Build()) + } + } + return dir +} + +// TestWriteTimeSeriesFiguresSelectsRepWithRawData verifies which repetition of +// a configuration gets time-series figures. The base named by the +// configuration's per-node CDF figure is preferred so both figures describe +// the same run, but a directory whose raw result files were only partly +// archived must fall back to a repetition that still has them instead of +// producing no figures at all. +func TestWriteTimeSeriesFiguresSelectsRepWithRawData(t *testing.T) { + const r1, r2 = "e1_Q_N1_W1_P0_r1", "e1_Q_N1_W1_P0_r2" + tests := []struct { + name string + withRaw []string + want []string + }{ + {"preferred rep has raw data", []string{r1}, []string{r1}}, + {"only another rep has raw data", []string{r2}, []string{r2}}, + {"both reps have raw data", []string{r1, r2}, []string{r1}}, + {"no rep has raw data", nil, nil}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + dir := timeSeriesFixture(t, []string{r1, r2}, test.withRaw, "") + out := filepath.Join(dir, reportSubdir) + + figures := writeTimeSeriesFigures(dir, out, reportManifests(t, dir), []string{r1}, nil, maxTimeSeriesRuns) + + var got []string + for _, f := range figures { + got = append(got, f.base) + if !slices.Equal(f.benches, []string{"Q"}) { + t.Errorf("%s benches = %v, want [Q]", f.base, f.benches) + } + if _, err := os.Stat(filepath.Join(out, "timeseries", f.base, "Q_throughput.csv")); err != nil { + t.Errorf("%s throughput CSV not written: %v", f.base, err) + } + } + if !slices.Equal(got, test.want) { + t.Errorf("time-series bases = %v, want %v", got, test.want) + } + }) + } +} + +// TestWriteTimeSeriesFiguresFromExportedEvents verifies that a compact-transfer +// directory renders time series from the exported plotdata/events.binpb, with no +// raw result file present at all: a compact transfer retains none for a +// successful run, which used to leave the report with no time-series figure. +func TestWriteTimeSeriesFiguresFromExportedEvents(t *testing.T) { + dir := t.TempDir() + const base = "e1_Q_N1_W1_P0_r1" + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, n, resultExt)}) + writePlotEventsFile(t, dir, benchkit.PlotEvents_builder{ + Runs: []*benchkit.PlotRunEvents{benchkit.PlotRunEvents_builder{ + Base: base, + Benchmarks: []*benchkit.PlotBenchmarkEvents{benchkit.PlotBenchmarkEvents_builder{ + Benchmark: "Q", + Nodes: []*benchkit.PlotNodeEvents{benchkit.PlotNodeEvents_builder{ + Node: "bb1:9000", + Events: []*benchkit.Event{tputEvent(0, 100), tputEvent(1_000_000_000, 200)}, + }.Build()}, + }.Build()}, + }.Build()}, + }.Build()) + + out := filepath.Join(dir, reportSubdir) + figures := writeTimeSeriesFigures(dir, out, reportManifests(t, dir), nil, nil, maxTimeSeriesRuns) + if len(figures) != 1 || figures[0].base != base || !slices.Equal(figures[0].benches, []string{"Q"}) { + t.Fatalf("figures = %+v, want one set for %s with benchmark Q", figures, base) + } + data, err := os.ReadFile(filepath.Join(out, "timeseries", base, "Q_throughput.csv")) + if err != nil { + t.Fatalf("read throughput CSV: %v", err) + } + if got := strings.Count(strings.TrimSpace(string(data)), "\n"); got != 2 { + t.Errorf("throughput CSV has %d data row(s), want 2\n%s", got, data) + } +} + +// TestGenerateReportTimeSeriesWithoutLatencyData verifies that a throughput-only +// benchmark gets its time-series figure. Its runs record no latency sample, so +// there is no per-node CDF data, which the selection used to be gated on even +// though the event stream carries valid throughput intervals. +func TestGenerateReportTimeSeriesWithoutLatencyData(t *testing.T) { + dir := t.TempDir() + const base = "e1_Q_N1_W1_P0_r1" + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 300, + // No Latencies and no Histogram: a server-measured throughput-only run. + Events: []*benchkit.Event{tputEvent(0, 100), tputEvent(1_000_000_000, 200)}, + }.Build()) + + if err := generateReport(dir, reportOptions{title: "Throughput only"}); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, reportSubdir) + if _, err := os.Stat(filepath.Join(out, "node_cdf.csv")); !os.IsNotExist(err) { + t.Errorf("node_cdf.csv written for a run with no latency samples: %v", err) + } + typ, err := os.ReadFile(filepath.Join(out, "report.typ")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(typ), "time-series(") { + t.Errorf("report.typ plans no time-series figure:\n%s", typ) + } +} + +// TestWriteFailedTimeSeriesFigures verifies the failed-runs budget and its +// caveat text: failed runs sharing a configuration and an error signature (which +// folds away the exit status) yield one representative figure, the cap drops the +// rest, and the note states how the run failed and how many of its nodes wrote +// no data at all. +func TestWriteFailedTimeSeriesFigures(t *testing.T) { + dir := t.TempDir() + n1 := nodeAssignment{host: "bb1", port: 9000} + n2 := nodeAssignment{host: "bb2", port: 9000} + // Three failures of one configuration, differing only in exit status, plus + // one of another configuration. + for rep, exit := range []int{1, 2, 3} { + base := "e1_Q_N1_W1_P0_r" + strconv.Itoa(rep+1) + writeFailedRun(t, dir, base, "", rep+1, exit, n1, n2) + } + writeFailedRun(t, dir, "e1_Q_N1_W1_P0_Sdedup_r1", "dedup", 1, 9, n1, n2) + + out := filepath.Join(dir, reportSubdir) + figures := writeFailedTimeSeriesFigures(dir, out, reportManifests(t, dir), nil, maxFailedRuns) + var got []string + for _, f := range figures { + got = append(got, f.base) + } + want := []string{"e1_Q_N1_W1_P0_Sdedup_r1", "e1_Q_N1_W1_P0_r1"} + if !slices.Equal(got, want) { + t.Fatalf("failed-run bases = %v, want %v (one per configuration and error signature)", got, want) + } + note := figures[1].note + for _, want := range []string{"failed during measurement", "1 of 2 nodes wrote no result file", "Representative of 3 failed runs"} { + if !strings.Contains(note, want) { + t.Errorf("note %q missing %q", note, want) + } + } + + // The cap drops whole groups, not runs within a group. + if capped := writeFailedTimeSeriesFigures(dir, out, reportManifests(t, dir), nil, 1); len(capped) != 1 { + t.Errorf("limit 1 produced %d figure set(s), want 1", len(capped)) + } +} + +// TestGenerateReportFailedRunSectionCompiles verifies the end-to-end failed-runs +// section: a sweep directory holding one successful and one failed run puts the +// failed run's trace under its own heading, with its caveat note, and the report +// still compiles with Typst. +func TestGenerateReportFailedRunSectionCompiles(t *testing.T) { + dir := t.TempDir() + n1 := nodeAssignment{host: "bb1", port: 9000} + n2 := nodeAssignment{host: "bb2", port: 9000} + const okBase = "e1_Q_N1_W1_P0_r1" + writePlotManifest(t, dir, okBase, runStatusSucceeded, 1, "", []string{resultFilename(okBase, n1, resultExt)}) + writePlotReport(t, dir, okBase, n1, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 100, + Latencies: []int64{1000, 2000}, + Events: []*benchkit.Event{tputEvent(0, 100), tputEvent(1_000_000_000, 100)}, + }.Build()) + writeFailedRun(t, dir, "e1_Q_N1_W1_P0_r2", "", 2, 1, n1, n2) + + if err := generateReport(dir, reportOptions{title: "Failed runs"}); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, reportSubdir) + typ, err := os.ReadFile(filepath.Join(out, "report.typ")) + if err != nil { + t.Fatal(err) + } + src := string(typ) + for _, want := range []string{ + `== #text("Failed runs")`, + `=== #text("Throughput and latency over time — Q, dual")`, + "e1_Q_N1_W1_P0_r2/Q_{throughput,latency,saturation}.csv", + "Run failed during measurement", + } { + if !strings.Contains(src, want) { + t.Errorf("report.typ missing %q\n---\n%s", want, src) + } + } + // The run base identifies the figure in its data note, not in its heading. + if strings.Contains(src, `#text("Throughput and latency over time — Q (e1_Q`) { + t.Errorf("report.typ still names the run base in a heading\n---\n%s", src) + } + + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping compile check") + } + cmd := exec.Command("typst", "compile", "report.typ", "report.pdf") + cmd.Dir = out + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile failed: %v\n%s", err, b) + } +} + +// writeFailedRun writes the manifest and the surviving node's result file of a +// failed run: node bb1 recorded events before the run failed, and the second +// node wrote nothing at all. +func writeFailedRun(t *testing.T, dir, base, streamMode string, rep, exitStatus int, present, missing nodeAssignment) { + t.Helper() + files := []string{resultFilename(base, present, resultExt), resultFilename(base, missing, resultExt)} + writePlotManifestWithStreamMode(t, dir, base, runStatusFailed, rep, "", streamMode, files) + if err := updateManifest(dir, base, func(m *runManifest) { + m.FailurePhase = failurePhaseMeasurement + m.Error = fmt.Sprintf("Process exited with status %d", exitStatus) + m.CollectedFiles = 1 + m.MissingFiles = files[1:] + }); err != nil { + t.Fatalf("update manifest: %v", err) + } + writePlotReport(t, dir, base, present, present.hostAddr(), benchkit.Result_builder{ + Config: plotRunConfigWithStreamMode("Q", 1, 1, 0, 0, streamMode), + Events: []*benchkit.Event{tputEvent(0, 100), tputEvent(1_000_000_000, 50)}, + }.Build()) +} + +// TestWriteTimeSeriesFiguresOnePerConfigurationUpToLimit verifies the figure +// budget: distinct configurations each contribute at most one run, and no more +// than limit runs are rendered in total, so a large sweep cannot produce a +// time-series figure per run. +func TestWriteTimeSeriesFiguresOnePerConfigurationUpToLimit(t *testing.T) { + var bases []string + dir := t.TempDir() + n := nodeAssignment{host: "bb1", port: 9000} + for _, mode := range []string{"dual", "dedup"} { + for rep := 1; rep <= 2; rep++ { + base := "e1_Q_N1_W1_P0_S" + mode + "_r" + strconv.Itoa(rep) + bases = append(bases, base) + writePlotManifestWithStreamMode(t, dir, base, runStatusSucceeded, rep, "", mode, + []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfigWithStreamMode("Q", 1, 1, 0, 0, mode), + Events: []*benchkit.Event{tputEvent(0, 100)}, + }.Build()) + } + } + out := filepath.Join(dir, reportSubdir) + + all := writeTimeSeriesFigures(dir, out, reportManifests(t, dir), []string{bases[0], bases[2]}, nil, maxTimeSeriesRuns) + var got []string + for _, f := range all { + got = append(got, f.base) + } + // Configurations follow the preferred order, so the figure budget is spent + // on the same runs the per-node CDF grid selected: the dual arm is named + // first in preferred, so it precedes the dedup one despite sorting after it. + if want := []string{bases[0], bases[2]}; !slices.Equal(got, want) { + t.Errorf("bases = %v, want %v (one per stream mode, preferred first)", got, want) + } + + capped := writeTimeSeriesFigures(dir, out, reportManifests(t, dir), []string{bases[0], bases[2]}, nil, 1) + if len(capped) != 1 { + t.Errorf("limit 1 produced %d figure set(s), want 1", len(capped)) + } +} + +// TestGenerateReportSaturationCurveNonRampedCompiles verifies the other edge +// case named in the saturation-curve follow-up: a non-ramped run (a single +// PhaseMarker_START and no RATE_STEP events) writes exactly one saturation +// row per node rather than zero, and the figure still compiles — a lone +// point is drawn as a marker (as metric-vs does), not silently dropped like +// time-series/per-node-cdf do for a single point. +func TestGenerateReportSaturationCurveNonRampedCompiles(t *testing.T) { + dir := t.TempDir() + const base = "run_Q_N1_W1_P0" + n := nodeAssignment{host: "bb1", port: 9000} + writePlotManifest(t, dir, base, runStatusSucceeded, 1, "", []string{resultFilename(base, n, resultExt)}) + writePlotReport(t, dir, base, n, "bb1:9000", benchkit.Result_builder{ + Config: plotRunConfig("Q", 1, 1, 0, 0), + Throughput: 100, + Latencies: []int64{1000, 2000}, + Events: []*benchkit.Event{ + benchkit.Event_builder{ + Offset: 0, + Phase: benchkit.PhaseMarker_builder{Phase: benchkit.PhaseMarker_START, Rate: 100}.Build(), + }.Build(), + benchkit.Event_builder{ + Offset: 0, + Throughput: benchkit.ThroughputInterval_builder{Ops: 100, Duration: 1_000_000_000}.Build(), + }.Build(), + benchkit.Event_builder{ + Offset: 0, + Latency: benchkit.LatencyInterval_builder{Mean: 1500, Stddev: 500, Count: 2}.Build(), + }.Build(), + }, + }.Build()) + + if err := generateReport(dir, reportOptions{title: "Test"}); err != nil { + t.Fatal(err) + } + out := filepath.Join(dir, reportSubdir) + + satPath := filepath.Join(out, "timeseries", base, "Q_saturation.csv") + data, err := os.ReadFile(satPath) + if err != nil { + t.Fatalf("read %s: %v", satPath, err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("Q_saturation.csv has %d lines, want 2 (header + one row per node)", len(lines)) + } + + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping compile check") + } + cmd := exec.Command("typst", "compile", "report.typ", "report.pdf") + cmd.Dir = out + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile failed: %v\n%s", err, b) + } +} diff --git a/benchkit/cmd/sweep/profiles.go b/benchkit/cmd/sweep/profiles.go new file mode 100644 index 00000000..0ca91b64 --- /dev/null +++ b/benchkit/cmd/sweep/profiles.go @@ -0,0 +1,54 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/google/pprof/profile" +) + +// Profile artifact extensions. The benchmark binary writes these next to its +// result file when sweep passes -cpuprofile/-memprofile (see -collect-profiles), +// and sweep downloads them alongside the result files. +const ( + cpuProfExt = ".cpu.prof" + memProfExt = ".mem.prof" +) + +// mergeCPUProfiles merges every *.cpu.prof in dir into dir/default.pgo, the +// filename the Go toolchain picks up for profile-guided optimization when +// placed in a main package directory. Unreadable profiles abort the merge so a +// corrupt input never silently skews the PGO profile. +func mergeCPUProfiles(dir string) error { + paths, err := filepath.Glob(filepath.Join(dir, "*"+cpuProfExt)) + if err != nil { + return err + } + if len(paths) == 0 { + return fmt.Errorf("no %s files in %s", cpuProfExt, dir) + } + profiles := make([]*profile.Profile, len(paths)) + for i, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if profiles[i], err = profile.ParseData(data); err != nil { + return fmt.Errorf("parse %s: %w", filepath.Base(path), err) + } + } + merged, err := profile.Merge(profiles) + if err != nil { + return fmt.Errorf("merge %d profiles: %w", len(profiles), err) + } + f, err := os.Create(filepath.Join(dir, "default.pgo")) + if err != nil { + return err + } + if err := merged.Write(f); err != nil { + f.Close() + return err + } + return f.Close() +} diff --git a/benchkit/cmd/sweep/profiles_test.go b/benchkit/cmd/sweep/profiles_test.go new file mode 100644 index 00000000..f6b066b3 --- /dev/null +++ b/benchkit/cmd/sweep/profiles_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/pprof/profile" +) + +// testCPUProfile builds a minimal valid CPU profile with one sample carrying +// the given cpu-nanoseconds value. +func testCPUProfile(t *testing.T, path string, value int64) { + t.Helper() + fn := &profile.Function{ID: 1, Name: "main.work", SystemName: "main.work", Filename: "work.go"} + loc := &profile.Location{ID: 1, Line: []profile.Line{{Function: fn, Line: 1}}} + p := &profile.Profile{ + SampleType: []*profile.ValueType{ + {Type: "samples", Unit: "count"}, + {Type: "cpu", Unit: "nanoseconds"}, + }, + Sample: []*profile.Sample{{Location: []*profile.Location{loc}, Value: []int64{1, value}}}, + Location: []*profile.Location{loc}, + Function: []*profile.Function{fn}, + PeriodType: &profile.ValueType{Type: "cpu", Unit: "nanoseconds"}, + Period: 10_000_000, + } + f, err := os.Create(path) + if err != nil { + t.Fatalf("create %s: %v", path, err) + } + defer f.Close() + if err := p.Write(f); err != nil { + t.Fatalf("write profile: %v", err) + } +} + +// TestMergeCPUProfiles verifies that mergeCPUProfiles merges every *.cpu.prof +// in the directory into a valid default.pgo whose sample values are the sum of +// the inputs. +func TestMergeCPUProfiles(t *testing.T) { + dir := t.TempDir() + testCPUProfile(t, filepath.Join(dir, "run_Q_N2_bb1_9000.cpu.prof"), 100) + testCPUProfile(t, filepath.Join(dir, "run_Q_N2_bb2_9000.cpu.prof"), 250) + + if err := mergeCPUProfiles(dir); err != nil { + t.Fatalf("mergeCPUProfiles: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "default.pgo")) + if err != nil { + t.Fatalf("read default.pgo: %v", err) + } + merged, err := profile.ParseData(data) + if err != nil { + t.Fatalf("parse default.pgo: %v", err) + } + var totalCPU int64 + for _, s := range merged.Sample { + totalCPU += s.Value[1] + } + if totalCPU != 350 { + t.Errorf("merged cpu value = %d, want 350", totalCPU) + } +} + +// TestMergeCPUProfilesNoInputs verifies that a directory without CPU profiles +// yields an error rather than an empty default.pgo. +func TestMergeCPUProfilesNoInputs(t *testing.T) { + if err := mergeCPUProfiles(t.TempDir()); err == nil { + t.Error("mergeCPUProfiles(empty dir) = nil error, want error") + } +} diff --git a/benchkit/cmd/sweep/remote_storage.go b/benchkit/cmd/sweep/remote_storage.go new file mode 100644 index 00000000..c04c395a --- /dev/null +++ b/benchkit/cmd/sweep/remote_storage.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "path" + "regexp" + "strings" + + "github.com/relab/iago" +) + +var remoteUserPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + +func remoteNamespace(ctx context.Context, host iago.Host, root string) (string, error) { + user := host.GetEnv("USER") + var err error + if strings.TrimSpace(user) == "" { + user, err = iago.Output(ctx, host, "id -un") + } + user = strings.TrimSpace(user) + if err != nil { + return "", fmt.Errorf("determine remote user on %s: %w", host.Name(), err) + } + if !remoteUserPattern.MatchString(user) { + return "", fmt.Errorf("unsafe remote user %q on %s", user, host.Name()) + } + return path.Join(root, "sweep-"+user), nil +} + +func ensureRemoteNamespace(ctx context.Context, host iago.Host, root string) (string, error) { + namespace, err := remoteNamespace(ctx, host, root) + if err != nil { + return "", err + } + cmd := "test -d " + iago.Quote(root) + " && test -w " + iago.Quote(root) + + " && mkdir -p " + iago.Quote(namespace) + " && test -w " + iago.Quote(namespace) + if err := driverExec(ctx, host, cmd); err != nil { + return "", fmt.Errorf("remote storage root %s on %s must exist and be writable: %w", root, host.Name(), err) + } + return namespace, nil +} diff --git a/benchkit/cmd/sweep/replay.go b/benchkit/cmd/sweep/replay.go new file mode 100644 index 00000000..7ca83282 --- /dev/null +++ b/benchkit/cmd/sweep/replay.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + + "github.com/relab/iago" +) + +const replayScriptName = "sweep.sh" +const rebuildSweepCommand = "make -C .. sweep" + +func writeReplayScript(outDir string, args []string) (string, error) { + path := filepath.Join(outDir, replayScriptName) + data := []byte(replayScript(args)) + if err := os.WriteFile(path, data, 0o755); err != nil { + return "", err + } + return path, nil +} + +func replayScript(args []string) string { + var b strings.Builder + b.WriteString("#!/bin/sh\n") + b.WriteString("set -eu\n\n") + b.WriteString("# Rebuild the sweep driver before replaying the experiment.\n") + b.WriteString(rebuildSweepCommand + "\n\n") + b.WriteString("exec ./cmd/sweep/sweep") + for _, arg := range args[1:] { + b.WriteByte(' ') + b.WriteString(iago.Quote(arg)) + } + b.WriteByte('\n') + return b.String() +} diff --git a/benchkit/cmd/sweep/replay_test.go b/benchkit/cmd/sweep/replay_test.go new file mode 100644 index 00000000..823eeac8 --- /dev/null +++ b/benchkit/cmd/sweep/replay_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReplayScript(t *testing.T) { + args := []string{ + "./cmd/sweep/sweep", + "-hosts", "bb[1-25]", + "-sweep", "e3-tlcurve", + "-n", "5,9,17,25", + "-workers", "1,2,4,8,16,32", + "-duration", "13s", + "-trim", "3s", + "-verbose", + "-benchmarks", "SymmetricQuorumCall,QuorumCall", + "-extra-args", "-label='canary run'", + } + want := "#!/bin/sh\n" + + "set -eu\n\n" + + "# Rebuild the sweep driver before replaying the experiment.\n" + + rebuildSweepCommand + "\n\n" + + "exec ./cmd/sweep/sweep '-hosts' 'bb[1-25]' '-sweep' 'e3-tlcurve'" + + " '-n' '5,9,17,25' '-workers' '1,2,4,8,16,32' '-duration' '13s' '-trim' '3s'" + + " '-verbose' '-benchmarks' 'SymmetricQuorumCall,QuorumCall'" + + " '-extra-args' '-label='\\''canary run'\\'''\n" + if got := replayScript(args); got != want { + t.Errorf("replayScript =\n%s\nwant\n%s", got, want) + } +} + +func TestReplayScriptShellQuoteEmptyArg(t *testing.T) { + got := replayScript([]string{"sweep", "-extra-args", ""}) + if !strings.Contains(got, "'-extra-args' ''\n") { + t.Errorf("replayScript did not quote empty arg:\n%s", got) + } +} + +func TestWriteReplayScript(t *testing.T) { + dir := t.TempDir() + path, err := writeReplayScript(dir, []string{"sweep", "-hosts", "bb1"}) + if err != nil { + t.Fatalf("writeReplayScript: %v", err) + } + if path != filepath.Join(dir, replayScriptName) { + t.Errorf("path = %q, want %q", path, filepath.Join(dir, replayScriptName)) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat replay script: %v", err) + } + if got := info.Mode().Perm(); got != 0o755 { + t.Errorf("mode = %v, want 0755", got) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read replay script: %v", err) + } + if !strings.Contains(string(data), rebuildSweepCommand+"\n\nexec ./cmd/sweep/sweep '-hosts' 'bb1'\n") { + t.Errorf("replay script content:\n%s", data) + } +} diff --git a/benchkit/cmd/sweep/report.go b/benchkit/cmd/sweep/report.go new file mode 100644 index 00000000..97077f85 --- /dev/null +++ b/benchkit/cmd/sweep/report.go @@ -0,0 +1,725 @@ +package main + +import ( + "cmp" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +// dimOrder is the sweep dimensions a figure can vary along or facet by, in a +// stable display order. +var dimOrder = func() []string { + var names []string + for _, dim := range dimensionSpecs { + if dim.name != "benchmark" && dim.name != "stream_mode" { + names = append(names, dim.name) + } + } + return names +}() + +// dimLabel is the x-axis label for each sweep dimension. +var dimLabel = func() map[string]string { + labels := make(map[string]string, len(dimensionSpecs)) + for _, dim := range dimensionSpecs { + labels[dim.name] = dim.label + } + return labels +}() + +// dimValue reads one sweep dimension from a record as a number. +func dimValue(r aggRunRecord, dim string) int { + value, _ := strconv.Atoi(dimensionValue(r.Dimensions, dim)) + return value +} + +// dimCounts returns the number of distinct values each sweep dimension takes. +func dimCounts(agg []aggRunRecord) map[string]int { + seen := map[string]map[int]bool{} + for _, d := range dimOrder { + seen[d] = map[int]bool{} + } + for _, r := range agg { + for _, d := range dimOrder { + seen[d][dimValue(r, d)] = true + } + } + out := map[string]int{} + for d, s := range seen { + out[d] = len(s) + } + return out +} + +// facetFor picks the dimension to facet a figure's panels by when its x-axis is +// xcol: the fewest-valued OTHER dimension that varies, chosen only when two or +// more other dimensions vary (a single varying dimension stays a set of lines +// in one panel). Ties break by dimOrder. Empty means a single panel. +func facetFor(counts map[string]int, xcol string) string { + best := "" + varying := 0 + for _, d := range dimOrder { + if d == xcol || counts[d] <= 1 { + continue + } + varying++ + if best == "" || counts[d] < counts[best] { + best = d + } + } + if varying < 2 { + return "" + } + return best +} + +// figureKind selects which library function renders a figureSpec. +type figureKind int + +const ( + kindMetricVs figureKind = iota // metric-vs against a sweep dimension (agg.csv) + kindTLCurve // throughput-latency curve (tl_curve.csv) + kindPerNodeCDF // per-node latency CDF grid, one panel per run (node_cdf.csv) + kindRatio // dedup/dual metric ratio vs a sweep dimension (comparison.csv) + kindNodeHealth // per-node throughput heatmap (node_health.csv) + kindDegradedShare // degraded-fraction heatmap (degraded_share.csv) + kindOffsetCDF // clock offset/drift CDF (offsets.csv) + kindRunStatus // run-outcome table (run_status.csv) + kindTimeSeries // throughput/latency/saturation over time for one run's benchmark (timeseries//_*.csv) +) + +// Report sections group the figures that answer one kind of question, so a +// reader can tell the sweep's headline results from its diagnostics. Each +// section starts a new page. +const ( + scalingSection = "Scaling" + loadCurveSection = "Load curves" + comparisonSection = "Stream-mode comparison" + healthSection = "Cluster health" + cdfSection = "Per-node latency" + timeSeriesSection = "Within-run time series" + failedRunsSection = "Failed runs" +) + +// sectionNotes is what each section says about its figures before the first one: +// what they show, and — where the report has to choose which runs to draw — how +// it chose them, which no individual figure is in a position to state. +var sectionNotes = map[string]string{ + scalingSection: "How each metric responds to one swept dimension, rep-averaged with the 95% confidence " + + "interval of the mean shaded. Panels facet the fewest-valued other dimension that varies; the legend " + + "names the dimensions that separate the series within a panel.", + loadCurveSection: "Median latency against the throughput each level of offered load achieved, one panel per " + + "cluster size, with the p95-p99 tail shaded above the line. Curves whose peak latencies span more than " + + "a factor of eight are split into scale bands, so one linear axis never crams widely differing curves.", + comparisonSection: "Each metric of the non-baseline stream mode divided by the baseline's, against a dashed " + + "parity line: above 1.0 the non-baseline mode is larger. Only configurations measured in both modes " + + "contribute.", + healthSection: "Whether the cluster, rather than the code under test, explains a measurement.", + cdfSection: "One panel per run, one curve per node, shaded light to dark in node order: a node whose latency " + + "distribution does not belong with its peers' separates from the bundle. Runs are chosen to spread " + + "across the sweep — a configuration earns a panel when it is the first to measure some dimension value — " + + "and the remaining panels are filled in run order.", + timeSeriesSection: "One run per configuration, taken in the order the per-node latency panels selected them " + + "and alternating stream modes so both arms of a comparison are covered. Every node of the run draws one " + + "line; consecutive figures over the same nodes share the legend of the first. A run that ramped its " + + "offered rate gets a third panel tracing achieved throughput against it.", + failedRunsSection: "A failed run has no aggregate row, so its throughput trace stands here instead: it shows " + + "whether its nodes were producing work at all, and when they stopped. One representative per " + + "configuration and error signature is drawn.", +} + +// figureSpec is one planned figure: which library call to emit and with what +// axes. yscale maps the CSV column unit to the display unit named by ylabel. +// section names the heading it sits under; note is a caveat printed under this +// figure. +type figureSpec struct { + kind figureKind + slug string + heading string + section string + note string + dataCSV string + // runScoped marks a figure whose heading already names the run or + // configuration it draws, so the sweep-wide subject is not appended to it. + runScoped bool + // metric-vs fields + xcol string + ycol string + bandCol string + ylabel string + yscale float64 + facet string + payloadPositive bool // restrict to payload>0 rows (goodput) + // tl-curve fields + group int + load string // the dimension the curve traces along (see tlLoadDims) + // per-node-cdf fields + runs []cdfRun + // time-series fields + base string + bench string // benchmark name within the run named by base + // sharesNodes marks a figure drawn over the same nodes, in the same order, + // as the preceding figure of its section: its node colors are those of that + // figure's legend, so it draws none of its own. + sharesNodes bool +} + +// cdfRun is one run drawn in the per-node latency CDF grid: the run base its +// rows are selected by, and the compact configuration label its panel carries. +type cdfRun struct { + base string + title string +} + +// timeSeriesRunFigures names the benchmarks one run base has time-series data +// for, produced by generateTimeSeries. title is the run's compact configuration +// label, which identifies its figures without repeating the run base in a +// heading. sharesNodes marks a run over the same nodes as the previously selected +// one, whose figures then share that run's legend. note carries the caveat a +// failed run's figures print (how it failed, which nodes are absent from the +// figure). +type timeSeriesRunFigures struct { + base string + title string + benches []string + sharesNodes bool + note string +} + +// reportInputs names the auxiliary datasets a report can draw beyond the +// tidy-long aggregate, so planFigures includes a figure only when its data was +// produced. cdfRuns lists the runs with per-node CDF data, one panel each; +// timeSeries names one run per configuration whose raw event data could be +// rendered — the configuration's cdfRuns entry when its raw per-node result +// files are still present, otherwise another repetition of the same +// configuration that has them (see writeTimeSeriesFigures). failedTimeSeries +// names the failed runs that get their own report section, which the +// per-configuration figures have no place for (see +// writeFailedTimeSeriesFigures). comparison holds the side-by-side mode rows +// behind comparison.csv, which planFigures consults per metric and per +// x-dimension rather than treating as one sweep-wide flag. +type reportInputs struct { + cdfRuns []cdfRun + timeSeries []timeSeriesRunFigures + failedTimeSeries []timeSeriesRunFigures + + comparison []comparisonRecord + nodeHealth bool + degradedShare bool + offsets bool + runStatus bool +} + +// ratioFigureMetrics are the metrics a ratio figure can draw, each naming its +// ratio column in comparison.csv, the figure's slug and heading stems, its +// y-axis label, and the aggStat the availability check reads. +var ratioFigureMetrics = []struct { + ratioCol string + slug string + heading string + ylabel string + get func(aggRunRecord) aggStat +}{ + { + ratioCol: "throughput_ratio", slug: "throughput_ratio", heading: "Throughput", + ylabel: "throughput ratio", get: func(r aggRunRecord) aggStat { return r.throughput }, + }, + { + ratioCol: "p50_ms_ratio", slug: "latency_ratio", heading: "Median-latency", + ylabel: "p50 ratio", get: func(r aggRunRecord) aggStat { return r.p50US }, + }, +} + +// planFigures decides which figures the data supports: a metric-vs-dimension +// figure is planned only when that dimension varies. For each such dimension it +// plans aggregate throughput and median latency; goodput is planned against +// payload and nodes when a non-zero payload varies; per-operation cost against +// nodes when nodes vary. A throughput-latency curve is planned per scale band +// when the worker count varies. The remaining figures are planned when their +// dataset is present per in. +func planFigures(agg []aggRunRecord, in reportInputs) []figureSpec { + counts := dimCounts(agg) + var specs []figureSpec + + // A throughput-only sweep (no latency samples or histogram recorded by + // any run) has no data for a latency figure to show. + hasLatency := slices.ContainsFunc(agg, func(r aggRunRecord) bool { return r.p50US.n > 0 }) + for _, x := range dimOrder { + if counts[x] <= 1 { + continue + } + facet := facetFor(counts, x) + specs = append(specs, figureSpec{ + slug: "throughput_vs_" + x, section: scalingSection, + heading: "Aggregate throughput vs. " + x, + dataCSV: "agg.csv", xcol: x, ycol: "throughput", bandCol: "throughput_ci95", + ylabel: "kops/s", yscale: 1.0 / 1e3, facet: facet, + }) + if hasLatency { + specs = append(specs, figureSpec{ + slug: "latency_vs_" + x, section: scalingSection, + heading: "Median latency vs. " + x, + dataCSV: "agg.csv", xcol: x, ycol: "p50_ms", bandCol: "p50_ms_ci95", + ylabel: "p50 (ms)", yscale: 1.0, facet: facet, + }) + } + } + + // Goodput (throughput × payload) when a non-zero payload varies. + if counts["payload"] > 1 && slices.ContainsFunc(agg, func(r aggRunRecord) bool { return r.Payload > 0 }) { + specs = append(specs, figureSpec{ + slug: "goodput_vs_payload", section: scalingSection, + heading: "Cluster byte throughput vs. payload", + dataCSV: "agg.csv", xcol: "payload", ycol: "goodput", bandCol: "goodput_ci95", + ylabel: "MB/s", yscale: 1.0 / 1e6, facet: facetFor(counts, "payload"), + payloadPositive: true, + }) + if counts["nodes"] > 1 { + specs = append(specs, figureSpec{ + slug: "goodput_vs_nodes", section: scalingSection, + heading: "Cluster byte throughput vs. nodes", + dataCSV: "agg.csv", xcol: "nodes", ycol: "goodput", bandCol: "goodput_ci95", + ylabel: "MB/s", yscale: 1.0 / 1e6, facet: facetFor(counts, "nodes"), + payloadPositive: true, + }) + } + } + + // Per-operation cost vs cluster size. + if counts["nodes"] > 1 { + specs = append(specs, + figureSpec{ + slug: "mem_per_op_vs_nodes", section: scalingSection, + heading: "Heap bytes per op vs. nodes", + dataCSV: "agg.csv", xcol: "nodes", ycol: "mem_per_op", bandCol: "mem_per_op_ci95", + ylabel: "bytes/op", yscale: 1.0, facet: facetFor(counts, "nodes"), + }, + figureSpec{ + slug: "allocs_per_op_vs_nodes", section: scalingSection, + heading: "Allocations per op vs. nodes", + dataCSV: "agg.csv", xcol: "nodes", ycol: "allocs_per_op", bandCol: "allocs_per_op_ci95", + ylabel: "allocs/op", yscale: 1.0, facet: facetFor(counts, "nodes"), + }, + ) + } + + // Throughput-latency curve, one figure per load dimension the sweep varied + // and per scale band within it. Either load dimension yields a curve, so a + // rate sweep at a fixed worker count gets one just as a worker sweep does. + loads := tlLoadDimensions(counts) + for _, load := range loads { + bands := tlGroups(agg, loads) + for _, g := range bands { + slug := "tl_curve_" + load + heading := "Aggregate throughput vs. latency over " + load + if len(bands) > 1 { + slug = fmt.Sprintf("%s_%d", slug, g) + heading = fmt.Sprintf("%s (scale band %d/%d)", heading, g, len(bands)) + } + specs = append(specs, figureSpec{ + kind: kindTLCurve, slug: slug, section: loadCurveSection, heading: heading, + dataCSV: "tl_curve.csv", group: g, load: load, + }) + } + } + + // dedup/dual (or non-baseline/baseline) comparison figures, one per metric + // per varying dimension, mirroring the metric-vs loop above: a comparison + // swept over any dimension (not just workers) gets a ratio figure against + // that dimension. Each figure is planned only when the paired comparison + // rows can actually draw it — the metric present in both modes, and one + // series varying along x — since ratio-vs drops a single-point series and + // would render nothing but the parity line. + for _, x := range dimOrder { + if counts[x] <= 1 { + continue + } + facet := facetFor(counts, x) + for _, m := range ratioFigureMetrics { + if !ratioAxisVaries(in.comparison, m.get, x, facet) { + continue + } + specs = append(specs, figureSpec{ + kind: kindRatio, slug: m.slug + "_vs_" + x, section: comparisonSection, + heading: m.heading + " ratio vs. " + x + " (non-baseline / baseline)", + dataCSV: "comparison.csv", xcol: x, ycol: m.ratioCol, ylabel: m.ylabel, facet: facet, + }) + } + } + + // Diagnostics. + if in.nodeHealth { + specs = append(specs, figureSpec{ + kind: kindNodeHealth, slug: "node_health", section: healthSection, + heading: "Per-node throughput relative to run median", + dataCSV: "node_health.csv", note: nodeHealthNote, + }) + } + if in.degradedShare { + specs = append(specs, figureSpec{ + kind: kindDegradedShare, slug: "degraded_share", section: healthSection, + heading: "Degraded-repetition fraction", + dataCSV: "degraded_share.csv", note: degradedShareNote, + }) + } + if in.offsets { + specs = append(specs, figureSpec{ + kind: kindOffsetCDF, slug: "clock_offsets", section: healthSection, + heading: "Clock offset and residual drift", + dataCSV: "offsets.csv", + }) + } + if in.runStatus { + specs = append(specs, figureSpec{ + kind: kindRunStatus, slug: "run_status", section: healthSection, + heading: "Run outcomes per node count", + dataCSV: "run_status.csv", + }) + } + + // Per-node latency CDF: one figure whose panels are the runs the caller + // selected, so a page shows a grid of runs rather than one run per page. + if len(in.cdfRuns) > 0 { + specs = append(specs, figureSpec{ + kind: kindPerNodeCDF, slug: "per_node_cdf", section: cdfSection, + heading: "Per-node latency CDF", + dataCSV: "node_cdf.csv", runs: in.cdfRuns, + }) + } + + // Throughput, latency, and (for a run that ramped its offered rate) the + // saturation curve over time, one figure per (run, benchmark) with raw event + // data. + for _, ts := range in.timeSeries { + specs = append(specs, timeSeriesFigures(ts, "time_series", timeSeriesSection)...) + } + + // Failed runs, last and under their own heading: a failed run has no + // aggregate row, so its trace cannot sit in the per-configuration structure + // above. Each carries the note stating how it failed and which nodes are + // absent from the figure. + for _, ts := range in.failedTimeSeries { + specs = append(specs, timeSeriesFigures(ts, "failed_time_series", failedRunsSection)...) + } + + if subject := figureSubject(agg); subject != "" { + for i, s := range specs { + if !s.runScoped { + specs[i].heading = s.heading + " — " + subject + } + } + } + return specs +} + +// timeSeriesFigures plans the over-time figures for one run, one per benchmark +// it recorded event data for, with slugs under the given stem and under the +// given section. Each figure's heading names the run by its compact +// configuration label rather than its base, which the figure's data note carries +// instead. +func timeSeriesFigures(ts timeSeriesRunFigures, slugStem, section string) []figureSpec { + specs := make([]figureSpec, 0, len(ts.benches)) + for _, bench := range ts.benches { + heading := "Throughput and latency over time" + if ts.title != "" { + heading += " — " + ts.title + } + specs = append(specs, figureSpec{ + kind: kindTimeSeries, section: section, + slug: slugStem + "_" + ts.base + "_" + bench, + heading: heading, + runScoped: ts.title != "", + note: ts.note, + dataCSV: timeSeriesDataNote(ts.base, bench), + base: ts.base, bench: bench, + sharesNodes: ts.sharesNodes, + }) + } + return specs +} + +// figureSubject names the categorical identity every figure of a report shares: +// the single benchmark the sweep measured and, when it compared none, the single +// stream mode. It belongs in the section headings, since a legend that repeats +// it on every entry spends the figure's width on what does not distinguish one +// series from another. It is empty when the sweep varied both, which the legends +// then carry. +func figureSubject(agg []aggRunRecord) string { + var parts []string + for _, dim := range []string{"benchmark", "stream_mode"} { + values := map[string]bool{} + for _, r := range agg { + values[dimensionValue(r.Dimensions, dim)] = true + } + if len(values) != 1 { + continue + } + for value := range values { + if value != "" { + parts = append(parts, value) + } + } + } + return strings.Join(parts, ", ") +} + +// Notes printed under the diagnostic figures, which show a distribution over +// runs rather than a measured metric and are read wrong without them. +const ( + nodeHealthNote = "Each cell is a host's median throughput across the repetitions of one configuration, " + + "divided by that run's median across hosts: a uniform cluster is green everywhere near 1.0, " + + "and a host behind a slow link or a throttled CPU stands out low. " + + "Grey means the host took no part in that configuration." + degradedShareNote = "Each cell is the fraction of one configuration's repetitions the sweep flagged degraded " + + "(a node whose throughput or latency did not belong with its peers'; see the run-outcome table). " + + "Green is zero and red is every repetition." +) + +// timeSeriesCSVPaths returns the throughput, latency, and saturation-curve +// CSV paths (relative to the report directory) generateTimeSeries wrote for +// one run's benchmark. +func timeSeriesCSVPaths(base, bench string) (tput, lat, sat string) { + dir := filepath.Join("timeseries", base) + return filepath.Join(dir, bench+"_throughput.csv"), + filepath.Join(dir, bench+"_latency.csv"), + filepath.Join(dir, bench+"_saturation.csv") +} + +// timeSeriesDataNote names the three CSVs one over-time figure reads, in the +// brace form a shell uses, so the note identifies the run without printing its +// directory three times. +func timeSeriesDataNote(base, bench string) string { + return filepath.Join("timeseries", base, bench) + "_{throughput,latency,saturation}.csv" +} + +// tlGroups returns the distinct scale-band group numbers present in the +// throughput-latency points, in ascending order. +func tlGroups(agg []aggRunRecord, loads []string) []int { + seen := map[int]bool{} + for _, p := range tlCurveRows(agg, loads) { + seen[p.group] = true + } + return slices.Sorted(maps.Keys(seen)) +} + +// reportHeader is what a report says about itself before its first figure: the +// title, and the one-line description of the experiment behind the data. The +// experiment line is where the sweep's identity and its fixed configuration +// live, so no figure heading has to repeat them. +type reportHeader struct { + title string + experiment string +} + +// experimentSummary describes the sweep behind a report in one line: the label +// that names it, every dimension it measured with the values it took, and the +// sweep-wide settings from its manifests. +func experimentSummary(agg []aggRunRecord, settings sweepSettings) string { + var parts []string + if settings.label != "" { + parts = append(parts, settings.label) + } + for _, dim := range dimensionSpecs { + values := dimensionSpread(agg, dim) + if len(values) == 0 { + continue + } + switch dim.name { + case "benchmark": + parts = append(parts, strings.Join(values, ", ")) + case "stream_mode": + parts = append(parts, "stream mode "+strings.Join(values, ", ")) + default: + parts = append(parts, strings.ReplaceAll(dim.name, "_", " ")+" "+strings.Join(values, ", ")) + } + } + scale := fmt.Sprintf("%d configurations", len(agg)) + if settings.runs > 0 { + scale += fmt.Sprintf(", %d runs", settings.runs) + } + parts = append(parts, scale) + if settings.duration != "" { + run := settings.duration + " per run" + if settings.trim != "" { + run += ", " + settings.trim + " trim" + } + parts = append(parts, run) + } + return strings.Join(parts, "; ") +} + +// dimensionSpread returns the distinct values a dimension took across the +// aggregate, numeric dimensions in ascending order and the rest alphabetically. +// A dimension left at its unset marker (0) yields nothing: the sweep did not +// configure it, so it describes no part of the experiment. +func dimensionSpread(agg []aggRunRecord, dim dimensionSpec) []string { + seen := map[string]bool{} + for _, r := range agg { + if value := dim.value(r.Dimensions); value != "" && value != "0" { + seen[value] = true + } + } + values := slices.Collect(maps.Keys(seen)) + slices.SortFunc(values, func(a, b string) int { + if dim.tag == "" { + return strings.Compare(a, b) + } + return cmp.Compare(atoiOr(a, 0), atoiOr(b, 0)) + }) + return values +} + +// reportPreamble styles the generated report: a large centered title, section +// headings that each open a page, and figure headings under them. +const reportPreamble = `#import "gorumsplot.typ": * +#set page(paper: "a4", margin: 2cm) +#set text(size: 10pt) +#show heading.where(level: 1): it => align(center, block(below: 0.7em, text(size: 20pt, weight: "bold", it.body))) +#show heading.where(level: 2): it => block(above: 0.4em, below: 0.8em, text(size: 14pt, weight: "bold", it.body)) +#show heading.where(level: 3): it => block(above: 1.1em, below: 0.6em, text(size: 11pt, weight: "bold", it.body)) +` + +// writeReportTyp emits a self-contained report.typ that imports the copied +// helper library, loads the CSVs, and renders each planned figure under its own +// heading, grouped into sections that each start a page. header names the report +// and the experiment behind it. +func writeReportTyp(path string, header reportHeader, specs []figureSpec) error { + var b strings.Builder + fmt.Fprint(&b, reportPreamble) + fmt.Fprintln(&b) + fmt.Fprintf(&b, "= #text(%q)\n\n", header.title) + if header.experiment != "" { + fmt.Fprintf(&b, "#align(center)[#emph[#text(%q)]]\n\n", header.experiment) + } + // Load only the CSVs the planned figures reference. + loaded := map[string]bool{} + for _, s := range specs { + v := csvVar[s.dataCSV] + if v == "" || loaded[v] { + continue + } + loaded[v] = true + fmt.Fprintf(&b, "#let %s = csv(%q, row-type: dictionary)\n", v, s.dataCSV) + } + fmt.Fprintln(&b) + + if len(specs) == 0 { + fmt.Fprintln(&b, "_No sweep dimension varies; nothing to plot._") + } + section := "" + for i, s := range specs { + if s.section != section { + section = s.section + // Each section opens a page, so a section's figures are read + // together and none is split across a page boundary that a + // preceding section's length happened to fall on. The break is weak, + // so the first section stays on the title page. + if i > 0 { + fmt.Fprintln(&b, "#pagebreak(weak: true)") + } + fmt.Fprintf(&b, "== #text(%q)\n\n", section) + if note := sectionNotes[section]; note != "" { + fmt.Fprintf(&b, "#emph[#text(%q)]\n\n", note) + } + } + fmt.Fprintf(&b, "=== #text(%q)\n\n", s.heading) + call := figureCall(s) + if s.kind == kindRunStatus { + fmt.Fprintln(&b, "#"+call) + } else { + fmt.Fprintln(&b, "#fitwidth("+call+")") + } + if s.note != "" { + fmt.Fprintf(&b, "\n#emph[#text(%q)]\n", s.note) + } + fmt.Fprintf(&b, "\n#emph[Data: `%s`.]\n\n", s.dataCSV) + } + return os.WriteFile(path, []byte(b.String()), 0o644) +} + +// csvVar maps a CSV filename to the Typst binding the report loads it into. A +// filename with no entry yields the empty string, and its figure is skipped. +var csvVar = map[string]string{ + "agg.csv": "agg", + "tl_curve.csv": "tl", + "node_cdf.csv": "cdf", + "comparison.csv": "cmp", + "node_health.csv": "nh", + "degraded_share.csv": "dg", + "offsets.csv": "off", + "run_status.csv": "st", +} + +// figureCall renders the Typst call that draws a figureSpec. +func figureCall(s figureSpec) string { + switch s.kind { + case kindTLCurve: + return fmt.Sprintf("tl-curve(tl, group: %d, load: %q)", s.group, s.load) + case kindPerNodeCDF: + var runs strings.Builder + for _, run := range s.runs { + fmt.Fprintf(&runs, "(base: %q, title: %q), ", run.base, run.title) + } + return fmt.Sprintf("per-node-cdf(cdf, (%s))", runs.String()) + case kindRatio: + facet, facetLabel := "none", "none" + if s.facet != "" { + facet = fmt.Sprintf("%q", s.facet) + // A plain Typst string, not a content block: the template + // concatenates it with "= " + value via the string "+" operator. + facetLabel = fmt.Sprintf("%q", dimLabel[s.facet]) + } + return fmt.Sprintf( + `ratio-vs(cmp, %q, xcol: %q, xlabel: [%s], ylabel: [%s], facet: %s, facet-label: %s)`, + s.ycol, s.xcol, dimLabel[s.xcol], s.ylabel, facet, facetLabel, + ) + case kindNodeHealth: + return `heatmap(nh, xcol: "col", ycol: "host", valuecol: "rel", ` + + `label: [host throughput / run median])` + case kindDegradedShare: + return `heatmap(dg, xcol: "col", ycol: "row", valuecol: "share", vmax: 1.0, reverse: true, ` + + `label: [degraded fraction])` + case kindOffsetCDF: + return "offset-cdf(off)" + case kindRunStatus: + return "run-status-table(st)" + case kindTimeSeries: + tput, lat, sat := timeSeriesCSVPaths(s.base, s.bench) + legend := "" + if s.sharesNodes { + legend = ", legend: false" + } + return fmt.Sprintf( + "time-series(csv(%q, row-type: dictionary), csv(%q, row-type: dictionary), "+ + "sat: csv(%q, row-type: dictionary)%s)", + tput, lat, sat, legend, + ) + default: + return metricVsCall(s) + } +} + +// metricVsCall renders the Typst call that draws a figureSpec via metric-vs. +func metricVsCall(s figureSpec) string { + data := "agg" + if s.payloadPositive { + data = `agg.filter(r => int(r.payload) > 0)` + } + facet, facetLabel := "none", "none" + if s.facet != "" { + facet = fmt.Sprintf("%q", s.facet) + // A plain Typst string, not a content block: the template + // concatenates it with "= " + value via the string "+" operator. + facetLabel = fmt.Sprintf("%q", dimLabel[s.facet]) + } + return fmt.Sprintf( + `metric-vs(%s, xcol: %q, ycol: %q, band-col: %q, ylabel: [%s], xlabel: [%s], yscale: %s, facet: %s, facet-label: %s)`, + data, s.xcol, s.ycol, s.bandCol, + s.ylabel, dimLabel[s.xcol], formatFloat(s.yscale), facet, facetLabel, + ) +} diff --git a/benchkit/cmd/sweep/report_test.go b/benchkit/cmd/sweep/report_test.go new file mode 100644 index 00000000..07534bee --- /dev/null +++ b/benchkit/cmd/sweep/report_test.go @@ -0,0 +1,781 @@ +package main + +import ( + "fmt" + "maps" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/relab/gorums/benchkit" +) + +func TestFacetFor(t *testing.T) { + tests := []struct { + name string + counts map[string]int + xcol string + want string + }{ + {"single-other-varies", map[string]int{"nodes": 1, "workers": 3, "payload": 1, "rate": 1}, "workers", ""}, + {"two-others-vary-fewest", map[string]int{"nodes": 2, "workers": 3, "payload": 5, "rate": 1}, "workers", "nodes"}, + {"x-excluded", map[string]int{"nodes": 4, "workers": 3, "payload": 2, "rate": 1}, "nodes", "payload"}, + {"none-vary", map[string]int{"nodes": 1, "workers": 3, "payload": 1, "rate": 1}, "payload", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := facetFor(tt.counts, tt.xcol); got != tt.want { + t.Errorf("facetFor(%v, %q) = %q, want %q", tt.counts, tt.xcol, got, tt.want) + } + }) + } +} + +// TestPlanFiguresBufferDimensions verifies that a swept buffer capacity is +// plotted like any other dimension, and that a buffer the sweep did not vary +// plans no figure. The unset marker is a constant, so it must not read as a +// varying dimension. +func TestPlanFiguresBufferDimensions(t *testing.T) { + tests := []struct { + name string + sendBuffers []int + recvBuffers []int + want []string + notWant []string + }{ + { + name: "SendBufferVaries", + sendBuffers: []int{64, 256, 1024}, + recvBuffers: []int{0}, + want: []string{"throughput_vs_send_buffer", "latency_vs_send_buffer"}, + notWant: []string{"throughput_vs_recv_buffer"}, + }, + { + name: "RecvBufferVaries", + sendBuffers: []int{0}, + recvBuffers: []int{0, 16}, + want: []string{"throughput_vs_recv_buffer", "latency_vs_recv_buffer"}, + notWant: []string{"throughput_vs_send_buffer"}, + }, + { + name: "NeitherVaries", + sendBuffers: []int{0}, + recvBuffers: []int{0}, + notWant: []string{"throughput_vs_send_buffer", "throughput_vs_recv_buffer"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var agg []aggRunRecord + for _, sb := range tt.sendBuffers { + for _, rb := range tt.recvBuffers { + agg = append(agg, aggRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: 1, + SendBuffer: sb, RecvBuffer: rb, StreamMode: "dual", + }, + reps: 3, throughput: aggStat{mean: 1000, n: 3}, + p50US: aggStat{mean: 1000, n: 3}, + }) + } + } + slugs := map[string]bool{} + for _, s := range planFigures(agg, reportInputs{}) { + slugs[s.slug] = true + } + for _, want := range tt.want { + if !slugs[want] { + t.Errorf("missing planned figure %q; got %v", want, slices.Sorted(maps.Keys(slugs))) + } + } + for _, bad := range tt.notWant { + if slugs[bad] { + t.Errorf("unexpected figure %q planned for a buffer that does not vary", bad) + } + } + }) + } +} + +// TestPlanFiguresOmitsLatencyWithoutData verifies that a throughput-only +// sweep (no run recorded a latency sample or histogram) plans no +// latency_vs_* figure, matching the goodput figure's existing Payload > 0 +// data-presence guard, instead of rendering a heading over empty panels. +func TestPlanFiguresOmitsLatencyWithoutData(t *testing.T) { + var agg []aggRunRecord + for _, w := range []int{2, 4, 8} { + agg = append(agg, aggRunRecord{ + Dimensions: benchkit.Dimensions{Benchmark: "M", Nodes: 3, Workers: w, StreamMode: "dual"}, + reps: 3, throughput: aggStat{mean: 1000, n: 3}, + // No p50US/p95US/p99US set: a server-measured throughput-only run. + }) + } + slugs := map[string]bool{} + for _, s := range planFigures(agg, reportInputs{}) { + slugs[s.slug] = true + } + if !slugs["throughput_vs_workers"] { + t.Error("missing throughput_vs_workers") + } + if slugs["latency_vs_workers"] { + t.Error("latency_vs_workers planned with no latency data in any run") + } +} + +func TestPlanFigures(t *testing.T) { + // workers and payload vary; nodes and rate fixed. + var agg []aggRunRecord + for _, w := range []int{2, 4, 8} { + for _, p := range []int{1024, 16384} { + for _, m := range []string{"dual", "dedup"} { + agg = append(agg, aggRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: w, Payload: p, StreamMode: m, + }, + reps: 3, throughput: aggStat{mean: 1000, n: 3}, + p50US: aggStat{mean: 1000, n: 3}, + }) + } + } + } + specs := planFigures(agg, reportInputs{}) + slugs := map[string]figureSpec{} + for _, s := range specs { + slugs[s.slug] = s + } + + // throughput/latency vs workers and vs payload; goodput vs payload; no + // nodes/rate figures (they don't vary); no goodput_vs_nodes (nodes fixed). + for _, want := range []string{ + "throughput_vs_workers", "latency_vs_workers", + "throughput_vs_payload", "latency_vs_payload", "goodput_vs_payload", + } { + if _, ok := slugs[want]; !ok { + t.Errorf("missing planned figure %q; got %v", want, slices.Sorted(maps.Keys(slugs))) + } + } + for _, bad := range []string{ + "throughput_vs_nodes", "throughput_vs_rate", "goodput_vs_nodes", "mem_per_op_vs_nodes", + } { + if _, ok := slugs[bad]; ok { + t.Errorf("unexpected figure %q planned", bad) + } + } + // With exactly two varying dims (workers, payload), each figure facets by + // the fewest-valued other dim. For x=workers the only other varying dim is + // payload -> single varying -> no facet. + if f := slugs["throughput_vs_workers"].facet; f != "" { + t.Errorf("throughput_vs_workers facet = %q, want none", f) + } + if !slugs["goodput_vs_payload"].payloadPositive { + t.Error("goodput figure should restrict to payload>0") + } +} + +// TestPlanFiguresRatioRequiresVaryingWorkers verifies that a comparison ratio +// figure is planned per varying dimension, mirroring the metric-vs loop: a +// dimension gets a ratio figure only while it varies, since ratio-vs plots +// against that dimension on a fixed x-axis and without a varying x every +// series collapses to one point and nothing is drawn but the parity line. A +// dual-vs-dedup comparison swept over nodes at fixed workers must plan the +// nodes-vs figure (not a workers one), and vice versa. +func TestPlanFiguresRatioRequiresVaryingWorkers(t *testing.T) { + rec := func(nodes, workers int, mode string) aggRunRecord { + return aggRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: nodes, Workers: workers, StreamMode: mode, + }, + reps: 3, throughput: aggStat{mean: 1000, n: 3}, + p50US: aggStat{mean: 1000, n: 3}, + } + } + + t.Run("WorkersFixedNodesVary", func(t *testing.T) { + var agg []aggRunRecord + for _, n := range []int{3, 9} { + for _, m := range []string{"dual", "dedup"} { + agg = append(agg, rec(n, 8, m)) + } + } + slugs := map[string]bool{} + for _, s := range planFigures(agg, reportInputs{comparison: pivotComparison(agg, "dual")}) { + slugs[s.slug] = true + } + for _, unwanted := range []string{"throughput_ratio_vs_workers", "latency_ratio_vs_workers"} { + if slugs[unwanted] { + t.Errorf("unexpected figure %q planned with workers fixed", unwanted) + } + } + for _, want := range []string{"throughput_ratio_vs_nodes", "latency_ratio_vs_nodes"} { + if !slugs[want] { + t.Errorf("missing planned figure %q; got %v", want, slices.Sorted(maps.Keys(slugs))) + } + } + }) + + t.Run("WorkersVary", func(t *testing.T) { + var agg []aggRunRecord + for _, w := range []int{2, 8} { + for _, m := range []string{"dual", "dedup"} { + agg = append(agg, rec(3, w, m)) + } + } + slugs := map[string]bool{} + for _, s := range planFigures(agg, reportInputs{comparison: pivotComparison(agg, "dual")}) { + slugs[s.slug] = true + } + for _, want := range []string{"throughput_ratio_vs_workers", "latency_ratio_vs_workers"} { + if !slugs[want] { + t.Errorf("missing planned figure %q; got %v", want, slices.Sorted(maps.Keys(slugs))) + } + } + }) +} + +// TestPlanFiguresRatioMetricAware verifies that ratio-figure planning consults +// the paired comparison rows per metric and per x-dimension, instead of one +// sweep-wide "some ratio is computable" boolean: a throughput-only comparison +// must plan no latency-ratio figure, and a dimension that varies across the +// sweep but not within any comparable series must plan no ratio figure at all, +// since ratio-vs would draw nothing but the parity line. +func TestPlanFiguresRatioMetricAware(t *testing.T) { + rec := func(nodes, workers int, mode string, latency bool) aggRunRecord { + r := aggRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: nodes, Workers: workers, StreamMode: mode, + }, + reps: 3, throughput: aggStat{mean: 1000, n: 3}, + } + if latency { + r.p50US = aggStat{mean: 1000, n: 3} + } + return r + } + + tests := []struct { + name string + agg []aggRunRecord + want []string + unwanted []string + }{ + { + name: "throughput only", + agg: []aggRunRecord{ + rec(3, 2, "dual", false), rec(3, 2, "dedup", false), + rec(3, 8, "dual", false), rec(3, 8, "dedup", false), + }, + want: []string{"throughput_ratio_vs_workers"}, + unwanted: []string{"latency_ratio_vs_workers"}, + }, + { + // nodes varies across the sweep, but only N=3 has both modes, so a + // nodes-axis series holds a single ratio point. + name: "x varies only outside the paired rows", + agg: []aggRunRecord{ + rec(3, 2, "dual", true), rec(3, 2, "dedup", true), + rec(3, 8, "dual", true), rec(3, 8, "dedup", true), + rec(9, 2, "dual", true), rec(9, 8, "dual", true), + }, + want: []string{"throughput_ratio_vs_workers", "latency_ratio_vs_workers"}, + unwanted: []string{ + "throughput_ratio_vs_nodes", "latency_ratio_vs_nodes", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + slugs := map[string]bool{} + for _, s := range planFigures(test.agg, reportInputs{comparison: pivotComparison(test.agg, "dual")}) { + slugs[s.slug] = true + } + for _, want := range test.want { + if !slugs[want] { + t.Errorf("missing planned figure %q; got %v", want, slices.Sorted(maps.Keys(slugs))) + } + } + for _, unwanted := range test.unwanted { + if slugs[unwanted] { + t.Errorf("unexpected figure %q planned", unwanted) + } + } + }) + } +} + +// TestMetricVsCallUsesHumanFacetLabel verifies that metricVsCall passes the +// facet dimension's human-readable dimLabel, not the raw CSV column name, so +// a facet panel titled "send_buffer = 4096" instead reads "Send queue +// capacity (requests) = 4096". Passing none for facet must also pass none +// for facet-label rather than an empty label. +func TestMetricVsCallUsesHumanFacetLabel(t *testing.T) { + withFacet := metricVsCall(figureSpec{ + xcol: "workers", ycol: "throughput", bandCol: "throughput_ci95", + facet: "send_buffer", + }) + if !strings.Contains(withFacet, `facet-label: "Send queue capacity (requests)"`) { + t.Errorf("metricVsCall missing human facet-label:\n%s", withFacet) + } + if strings.Contains(withFacet, `facet-label: "send_buffer"`) { + t.Errorf("metricVsCall used the raw column name as facet-label:\n%s", withFacet) + } + + withoutFacet := metricVsCall(figureSpec{xcol: "workers", ycol: "throughput", bandCol: "throughput_ci95"}) + if !strings.Contains(withoutFacet, "facet-label: none") { + t.Errorf("metricVsCall without a facet should pass facet-label: none:\n%s", withoutFacet) + } +} + +// TestPlanFiguresTLCurveLoadDimensions verifies that a throughput-latency curve +// is planned for whichever load dimension the sweep varied: a rate sweep at a +// fixed worker count traces the curve along the rate, which used to plan no +// curve at all, and a sweep varying both gets one figure per dimension. +func TestPlanFiguresTLCurveLoadDimensions(t *testing.T) { + rec := func(workers, rate int) aggRunRecord { + return aggRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: workers, Rate: rate, StreamMode: "dual", + }, + reps: 3, + throughput: aggStat{mean: float64(rate), n: 3}, + p50US: aggStat{mean: float64(rate) / 10, n: 3}, + p95US: aggStat{mean: float64(rate) / 8, n: 3}, + p99US: aggStat{mean: float64(rate) / 5, n: 3}, + } + } + tests := []struct { + name string + agg []aggRunRecord + want map[string]string // figure slug -> load dimension + }{ + { + name: "rate varies at fixed workers", + agg: []aggRunRecord{rec(32, 1000), rec(32, 2000), rec(32, 3000)}, + want: map[string]string{"tl_curve_rate": "rate"}, + }, + { + name: "workers varies at fixed rate", + agg: []aggRunRecord{rec(2, 0), rec(4, 0), rec(8, 0)}, + want: map[string]string{"tl_curve_workers": "workers"}, + }, + { + name: "both vary", + agg: []aggRunRecord{ + rec(2, 1000), rec(4, 1000), rec(2, 2000), rec(4, 2000), + }, + want: map[string]string{"tl_curve_workers": "workers", "tl_curve_rate": "rate"}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + loads := map[string]string{} + for _, s := range planFigures(test.agg, reportInputs{}) { + if s.kind == kindTLCurve { + loads[s.slug] = s.load + } + } + if !maps.Equal(loads, test.want) { + t.Errorf("tl-curve figures = %v, want %v", loads, test.want) + } + }) + } +} + +// TestFigureSubject verifies that the identity every figure shares — the single +// benchmark, and the stream mode when the sweep compared only one — is reported +// for the section headings to carry, and that a dimension the sweep varied is +// not, since the legends distinguish series by it. +func TestFigureSubject(t *testing.T) { + rec := func(bench, mode string) aggRunRecord { + return aggRunRecord{Dimensions: benchkit.Dimensions{Benchmark: bench, Nodes: 3, StreamMode: mode}} + } + tests := []struct { + name string + agg []aggRunRecord + want string + }{ + {"one benchmark, one mode", []aggRunRecord{rec("Q", "dual")}, "Q, dual"}, + {"one benchmark, two modes", []aggRunRecord{rec("Q", "dual"), rec("Q", "dedup")}, "Q"}, + {"two benchmarks, one mode", []aggRunRecord{rec("Q", "dual"), rec("M", "dual")}, "dual"}, + {"both vary", []aggRunRecord{rec("Q", "dual"), rec("M", "dedup")}, ""}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := figureSubject(test.agg); got != test.want { + t.Errorf("figureSubject = %q, want %q", got, test.want) + } + }) + } +} + +// TestExperimentSummary verifies the line printed under the report title: it +// names the sweep, every dimension with the values it took (numeric ones in +// ascending order, unset ones left out), and the sweep-wide settings, so no +// figure heading has to repeat the fixed configuration. +func TestExperimentSummary(t *testing.T) { + var agg []aggRunRecord + for _, n := range []int{15, 9} { + for _, mode := range []string{"dual", "dedup"} { + agg = append(agg, aggRunRecord{Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: n, Workers: 32, StreamMode: mode, + }}) + } + } + got := experimentSummary(agg, sweepSettings{ + label: "eval-v1", duration: "20s", trim: "2s", runs: 40, + }) + want := "eval-v1; Q; nodes 9, 15; workers 32; stream mode dedup, dual; " + + "4 configurations, 40 runs; 20s per run, 2s trim" + if got != want { + t.Errorf("experimentSummary =\n%q\nwant\n%q", got, want) + } + if strings.Contains(got, "rate") || strings.Contains(got, "buffer") { + t.Errorf("experimentSummary names a dimension the sweep left unset: %q", got) + } +} + +func TestWriteReportTyp(t *testing.T) { + specs := []figureSpec{ + { + slug: "throughput_vs_workers", section: scalingSection, heading: "Aggregate #throughput [raw]", + dataCSV: "agg.csv", xcol: "workers", ycol: "throughput", bandCol: "throughput_ci95", + ylabel: "kops/s", yscale: 1.0 / 1e3, facet: "payload", + }, + { + kind: kindRunStatus, slug: "run_status", section: healthSection, + heading: "Run outcomes", dataCSV: "run_status.csv", + }, + } + path := filepath.Join(t.TempDir(), "report.typ") + if err := writeReportTyp(path, reportHeader{title: "Test #report [raw]", experiment: "Q; nodes 3"}, specs); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + src := string(data) + for _, want := range []string{ + `#import "gorumsplot.typ": *`, + `= #text("Test #report [raw]")`, + `#align(center)[#emph[#text("Q; nodes 3")]]`, + // Sections head the figures, which sit one level deeper. + `== #text("Scaling")`, + `=== #text("Aggregate #throughput [raw]")`, + `== #text("Cluster health")`, + `=== #text("Run outcomes")`, + // The second section opens a page; the first stays with the title. + "#pagebreak(weak: true)\n== #text(\"Cluster health\")", + `csv("agg.csv"`, + `metric-vs(agg, xcol: "workers", ycol: "throughput", band-col: "throughput_ci95"`, + `facet: "payload"`, + `#run-status-table(st)`, + } { + if !strings.Contains(src, want) { + t.Errorf("report.typ missing %q\n---\n%s", want, src) + } + } + if strings.Contains(src, "#pagebreak(weak: true)\n== #text(\"Scaling\")") { + t.Errorf("the first section should not open a page of its own\n---\n%s", src) + } + if note := sectionNotes[scalingSection]; !strings.Contains(src, note) { + t.Errorf("report.typ missing the %q section note\n---\n%s", scalingSection, src) + } + if strings.Contains(src, "#fitwidth(run-status-table") { + t.Errorf("run-status table should keep its intrinsic width\n---\n%s", src) + } +} + +// TestGenerateReportCompiles writes a full agg.csv from synthetic records, +// generates report.typ + the helper lib, and compiles it with Typst. It is +// skipped when the typst binary is not on PATH, so it stays a no-op in CI +// environments without Typst while catching template/CSV breakage locally. +func TestGenerateReportCompiles(t *testing.T) { + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping report compile check") + } + var runs []plotRunRecord + for _, n := range []int{3, 9} { + for _, w := range []int{2, 4, 8, 16} { + for _, p := range []int{0, 1024, 16384} { + for _, sendBuffer := range []int{0, 256} { + for _, m := range []string{"dual", "dedup"} { + lat := 500.0 + float64(w*50) + float64(p)/100 + thr := float64(w) * 5000 * float64(n) / 3 + // Two reps so spread columns are populated. + for _, jitter := range []float64{0.98, 1.02} { + runs = append(runs, plotRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: n, Workers: w, Payload: p, + SendBuffer: sendBuffer, StreamMode: m, + }, + status: runStatusSucceeded, + throughput: thr * jitter, + allocsPerOp: 12, memPerOp: 2048, + meanUS: new(lat), p50US: new(lat), p95US: new(lat * 1.5), p99US: new(lat * 2), + }) + } + } + } + } + } + } + agg := aggregateReps(runs, false) + + dir := t.TempDir() + if err := writeAggRunsCSV(filepath.Join(dir, "agg.csv"), agg); err != nil { + t.Fatal(err) + } + if err := writeTLCurveCSV(filepath.Join(dir, "tl_curve.csv"), tlCurveRows(agg, []string{"workers"})); err != nil { + t.Fatal(err) + } + // A minimal per-node CDF for one run so the CDF figure is exercised too. + base := "run_Q_N3_W8_P1024" + var cdfRows []plotNodeCDFRecord + for _, node := range []string{"bb1:9000", "bb2:9000"} { + for i := 0; i <= 20; i++ { + prob := float64(i) / 20 + cdfRows = append(cdfRows, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: 3, Workers: 8, Payload: 1024, StreamMode: "dual", + }, + base: base, label: "run", status: runStatusSucceeded, rep: 1, + node: node, throughput: 70000, prob: prob, cdfUS: 200 + 800*prob, + }) + } + } + if err := writePlotNodeCDFCSV(filepath.Join(dir, "node_cdf.csv"), cdfRows); err != nil { + t.Fatal(err) + } + if err := copyReportLib(dir); err != nil { + t.Fatal(err) + } + specs := planFigures(agg, reportInputs{cdfRuns: []cdfRun{{base: base, title: "N3 W8 P1024"}}}) + if len(specs) == 0 { + t.Fatal("no figures planned") + } + // The full figure set should include a tl-curve and the per-node CDF. + kinds := map[figureKind]bool{} + for _, s := range specs { + kinds[s.kind] = true + } + if !kinds[kindTLCurve] || !kinds[kindPerNodeCDF] { + t.Errorf("expected tl-curve and per-node-cdf figures; kinds=%v", kinds) + } + if err := writeReportTyp(filepath.Join(dir, "report.typ"), reportHeader{title: "Compile test"}, specs); err != nil { + t.Fatal(err) + } + cmd := exec.Command("typst", "compile", "report.typ", "report.pdf") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile failed: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(dir, "report.pdf")); err != nil { + t.Fatalf("report.pdf not produced: %v", err) + } +} + +// TestTypstFiguresCompileWithEmptyData verifies the helper library's empty-data +// guards: every figure function called with no rows must render nothing instead +// of failing the compilation. The hazard is grid(columns: 0), which Typst +// rejects with "number must be positive", reachable through an empty legend or +// an empty panel list. It is skipped when the typst binary is not on PATH, like +// TestGenerateReportCompiles. +func TestTypstFiguresCompileWithEmptyData(t *testing.T) { + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping report compile check") + } + dir := t.TempDir() + if err := copyReportLib(dir); err != nil { + t.Fatal(err) + } + src := strings.Join([]string{ + `#import "gorumsplot.typ": *`, + `#hlegend(())`, + // An odd entry count over several rows leaves the last column ragged, + // so the grid is handed an empty cell. + `#hlegend(((red, "a"), (blue, "b"), (green, "c")), cols: 2)`, + `#time-series((), (), sat: ())`, + `#per-node-cdf((), ())`, + `#per-node-cdf((), ((base: "no-such-base", title: "N3"),))`, + `#heatmap(())`, + "", + }, "\n") + if err := os.WriteFile(filepath.Join(dir, "empty.typ"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("typst", "compile", "empty.typ", "empty.pdf") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile of empty figures failed: %v\n%s", err, out) + } +} + +// TestGenerateReportRateTLCurveCompiles verifies that a rate sweep at a fixed +// worker count — the shape of the paced dedup evaluations, which used to get no +// throughput-latency figure at all — plans a curve traced along the rate and +// renders it, with a nine-panel per-node CDF grid beside it. It is skipped when +// the typst binary is not on PATH, like TestGenerateReportCompiles. +func TestGenerateReportRateTLCurveCompiles(t *testing.T) { + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping report compile check") + } + var runs []plotRunRecord + var cdfRows []plotNodeCDFRecord + var cdfSet []cdfRun + for _, n := range []int{3, 9, 27} { + for _, rate := range []int{1000, 2000, 3000} { + for _, mode := range []string{"dual", "dedup"} { + // Latency rises with the offered rate, so the curve has shape. + lat := 400.0 + float64(rate)/4 + thr := float64(rate) * float64(n) + for _, jitter := range []float64{0.98, 1.02} { + runs = append(runs, plotRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: n, Workers: 32, Payload: 1024, + Rate: rate, StreamMode: mode, + }, + status: runStatusSucceeded, + throughput: thr * jitter, + allocsPerOp: 12, memPerOp: 2048, + meanUS: new(lat), p50US: new(lat), p95US: new(lat * 1.5), p99US: new(lat * 2), + }) + } + base := fmt.Sprintf("run_Q_N%d_R%d_S%s", n, rate, mode) + cdfSet = append(cdfSet, cdfRun{base: base, title: fmt.Sprintf("N%d R%d %s", n, rate, mode)}) + for _, node := range []string{"bb1:9000", "bb2:9000"} { + for i := 0; i <= 20; i++ { + prob := float64(i) / 20 + cdfRows = append(cdfRows, plotNodeCDFRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: n, Workers: 32, Payload: 1024, + Rate: rate, StreamMode: mode, + }, + base: base, label: "run", status: runStatusSucceeded, rep: 1, + node: node, throughput: thr, prob: prob, cdfUS: lat + 800*prob, + }) + } + } + } + } + } + agg := aggregateReps(runs, false) + + dir := t.TempDir() + if err := writeAggRunsCSV(filepath.Join(dir, "agg.csv"), agg); err != nil { + t.Fatal(err) + } + if err := writeTLCurveCSV(filepath.Join(dir, "tl_curve.csv"), tlCurveRows(agg, []string{"rate"})); err != nil { + t.Fatal(err) + } + if err := writePlotNodeCDFCSV(filepath.Join(dir, "node_cdf.csv"), cdfRows); err != nil { + t.Fatal(err) + } + if err := copyReportLib(dir); err != nil { + t.Fatal(err) + } + + specs := planFigures(agg, reportInputs{cdfRuns: cdfSet[:maxCDFRuns]}) + var tl figureSpec + for _, s := range specs { + if s.kind == kindTLCurve { + tl = s + } + if s.slug == "tl_curve_workers" { + t.Errorf("planned a workers-traced curve with the worker count fixed") + } + } + if tl.load != "rate" { + t.Fatalf("tl-curve load = %q, want %q", tl.load, "rate") + } + if err := writeReportTyp(filepath.Join(dir, "report.typ"), + reportHeader{title: "Rate TL-curve compile test", experiment: experimentSummary(agg, sweepSettings{})}, + specs); err != nil { + t.Fatal(err) + } + cmd := exec.Command("typst", "compile", "report.typ", "report.pdf") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile failed: %v\n%s", err, out) + } +} + +// TestGenerateReportRatioVsNodesCompiles verifies the exact failure scenario +// from the ratio-figures follow-up: a dual-vs-dedup comparison swept over +// nodes at a fixed worker count must plan and render a ratio figure against +// nodes, not workers, and the render must show real (non-parity) points, not +// just the dashed 1.0 line. It is skipped when the typst binary is not on +// PATH, like TestGenerateReportCompiles. +func TestGenerateReportRatioVsNodesCompiles(t *testing.T) { + if _, err := exec.LookPath("typst"); err != nil { + t.Skip("typst not on PATH; skipping report compile check") + } + var runs []plotRunRecord + for _, n := range []int{3, 9} { + for _, m := range []string{"dual", "dedup"} { + // dedup measurably faster than dual so the ratio isn't 1.0. + thr := 5000.0 * float64(n) + lat := 1000.0 + if m == "dedup" { + thr *= 1.2 + lat *= 0.8 + } + for _, jitter := range []float64{0.98, 1.02} { + runs = append(runs, plotRunRecord{ + Dimensions: benchkit.Dimensions{ + Benchmark: "Q", Nodes: n, Workers: 8, StreamMode: m, + }, + status: runStatusSucceeded, + throughput: thr * jitter, + allocsPerOp: 12, memPerOp: 2048, + meanUS: new(lat), p50US: new(lat), p95US: new(lat * 1.5), p99US: new(lat * 2), + }) + } + } + } + agg := aggregateReps(runs, false) + + dir := t.TempDir() + if err := writeAggRunsCSV(filepath.Join(dir, "agg.csv"), agg); err != nil { + t.Fatal(err) + } + cmpRows := pivotComparison(agg, "dual") + if err := writeComparisonCSV(filepath.Join(dir, "comparison.csv"), cmpRows); err != nil { + t.Fatal(err) + } + if err := copyReportLib(dir); err != nil { + t.Fatal(err) + } + + specs := planFigures(agg, reportInputs{comparison: cmpRows}) + var ratioSpec figureSpec + found := false + for _, s := range specs { + if s.slug == "throughput_ratio_vs_workers" || s.slug == "latency_ratio_vs_workers" { + t.Errorf("unexpected workers-vs ratio figure %q with workers fixed", s.slug) + } + if s.slug == "throughput_ratio_vs_nodes" { + ratioSpec, found = s, true + } + } + if !found { + t.Fatal("throughput_ratio_vs_nodes not planned") + } + if ratioSpec.xcol != "nodes" { + t.Errorf("ratio figure xcol = %q, want %q", ratioSpec.xcol, "nodes") + } + + if err := writeReportTyp(filepath.Join(dir, "report.typ"), reportHeader{title: "Ratio-vs-nodes compile test"}, specs); err != nil { + t.Fatal(err) + } + cmd := exec.Command("typst", "compile", "report.typ", "report.pdf") + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("typst compile failed: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(dir, "report.pdf")); err != nil { + t.Fatalf("report.pdf not produced: %v", err) + } +} diff --git a/benchkit/cmd/sweep/runlist.go b/benchkit/cmd/sweep/runlist.go new file mode 100644 index 00000000..18ec549f --- /dev/null +++ b/benchkit/cmd/sweep/runlist.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/relab/iago" +) + +func runDriverList(cfg *config) error { + if cfg.driver == "" { + return errors.New("-list requires -driver or a saved driver in .sweep-last.json") + } + group, err := dialDriverGroup(cfg.driver, cfg.sshConfig) + if err != nil { + return fmt.Errorf("connect to driver: %w", err) + } + defer group.Close() + host := group.Hosts[0] + latest := "" + namespace := "" + if state, err := readLastRunState(cfg.rootDir); err == nil && state.Driver == cfg.driver { + latest = state.RemoteWorkDir + namespace = state.RemoteNamespace + } + if namespace == "" { + namespace, err = remoteNamespace(context.Background(), host, cfg.remoteDir) + if err != nil { + return err + } + } + script := `set -eu +ns=$1 +[ -d "$ns" ] || exit 0 +find "$ns" -mindepth 1 -maxdepth 1 -type d -name 'sweep-driver-*' -print0 | + xargs -0 -r ls -1dt | + while IFS= read -r wd; do + status=recoverable + exit_code=- + [ -f "$wd/exit.code" ] && exit_code=$(cat "$wd/exit.code") + if [ ! -f "$wd/exit.code" ]; then + status=active + elif [ -f "$wd/compact.collected" ]; then + status=raw-pending + elif find "$wd/out" -type d -name '` + compactTransferDir + `' -print -quit 2>/dev/null | grep -q .; then + status=completed + fi + label=$(sed -n 's/.*"label":"\([^"]*\)".*/\1/p' "$wd/run.meta.json" 2>/dev/null) + [ -n "$label" ] || label=$(basename "$wd") + started=$(sed -n 's/.*"launched_at":"\([^"]*\)".*/\1/p' "$wd/run.meta.json" 2>/dev/null) + [ -n "$started" ] || started=$(stat -c %y "$wd" 2>/dev/null | cut -d. -f1 || stat -f '%Sm' -t '%Y-%m-%d %H:%M:%S' "$wd") + size=$(du -sh "$wd" 2>/dev/null | awk '{print $1}') + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$started" "$label" "$status" "$exit_code" "$size" "$wd" + done +` + out, err := iago.Output(context.Background(), host, "sh -c "+iago.Quote(script)+" sh "+iago.Quote(namespace)) + if err != nil { + return err + } + return writeDriverList(os.Stdout, cfg.driver, namespace, latest, out) +} + +// writeDriverList renders tab-delimited driver run data as aligned columns. +func writeDriverList(w io.Writer, driver, namespace, latest, rows string) error { + if _, err := fmt.Fprintf(w, "DRIVER %s NAMESPACE %s\n", driver, namespace); err != nil { + return err + } + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "STARTED\tLABEL\tSTATUS\tEXIT\tSIZE\tPATH"); err != nil { + return err + } + for line := range strings.SplitSeq(strings.TrimSpace(rows), "\n") { + if line == "" { + continue + } + if started, rest, ok := strings.Cut(line, "\t"); ok { + line = formatRunListTimestamp(started) + "\t" + rest + } + if latest != "" && strings.HasSuffix(line, "\t"+latest) { + line += " (latest)" + } + if _, err := fmt.Fprintln(tw, line); err != nil { + return err + } + } + return tw.Flush() +} + +func formatRunListTimestamp(timestamp string) string { + started, err := time.Parse(time.RFC3339Nano, timestamp) + if err != nil { + return timestamp + } + return started.Format("2006-01-02 15:04:05") +} diff --git a/benchkit/cmd/sweep/runlist_test.go b/benchkit/cmd/sweep/runlist_test.go new file mode 100644 index 00000000..2704f4e7 --- /dev/null +++ b/benchkit/cmd/sweep/runlist_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "slices" + "strings" + "testing" +) + +// TestFormatRunListTimestamp verifies that list output uses a human-readable +// local date and time at second precision. +func TestFormatRunListTimestamp(t *testing.T) { + tests := []struct { + name string + timestamp string + want string + }{ + { + name: "microseconds", + timestamp: "2026-07-29T16:37:33.616046-07:00", + want: "2026-07-29 16:37:33", + }, + { + name: "fractional seconds", + timestamp: "2026-07-28T13:54:33.51098-07:00", + want: "2026-07-28 13:54:33", + }, + { + name: "seconds", + timestamp: "2026-07-27T22:57:41-07:00", + want: "2026-07-27 22:57:41", + }, + { + name: "stat fallback", + timestamp: "2026-07-27 22:57:41", + want: "2026-07-27 22:57:41", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := formatRunListTimestamp(tt.timestamp); got != tt.want { + t.Errorf("formatRunListTimestamp(%q) = %q, want %q", tt.timestamp, got, tt.want) + } + }) + } +} + +// TestWriteDriverListAlignsColumns verifies that values of different lengths +// do not shift columns away from their headings. +func TestWriteDriverListAlignsColumns(t *testing.T) { + const ( + namespace = "/tmp/sweep-meling" + latest = namespace + "/sweep-driver-dedup-qc-v3-20260729_163732" + ) + rows := strings.Join([]string{ + "2026-07-29T16:37:33.616046-07:00\tdedup-qc-v3\tcompleted\t0\t117M\t" + latest, + "2026-07-28T13:54:33.51098-07:00\tsymmetric-qc-dedup-eval-v14\traw-pending\t0\t1.2G\t" + namespace + "/sweep-driver-symmetric-qc-dedup-eval-v14-20260728_135433", + }, "\n") + + var output strings.Builder + if err := writeDriverList(&output, "bb1", namespace, latest, rows); err != nil { + t.Fatalf("writeDriverList() error = %v", err) + } + + lines := strings.Split(strings.TrimSpace(output.String()), "\n") + if len(lines) != 4 { + t.Fatalf("output has %d lines, want 4:\n%s", len(lines), output.String()) + } + if strings.Contains(output.String(), "\t") { + t.Fatalf("output contains unexpanded tabs:\n%s", output.String()) + } + + columns := [][]string{ + {"STARTED", "LABEL", "STATUS", "EXIT", "SIZE", "PATH"}, + {"2026-07-29 16:37:33", "dedup-qc-v3", "completed", "0", "117M", latest}, + {"2026-07-28 13:54:33", "symmetric-qc-dedup-eval-v14", "raw-pending", "0", "1.2G", namespace + "/sweep-driver-symmetric-qc-dedup-eval-v14-20260728_135433"}, + } + starts := columnStarts(t, lines[1], columns[0]) + for i, fields := range columns[1:] { + if got := columnStarts(t, lines[i+2], fields); !slices.Equal(got, starts) { + t.Errorf("line %d column starts = %v, want %v:\n%s", i+3, got, starts, lines[i+2]) + } + } + if !strings.HasSuffix(lines[2], latest+" (latest)") { + t.Errorf("latest row missing marker:\n%s", lines[2]) + } +} + +func columnStarts(t *testing.T, line string, fields []string) []int { + t.Helper() + starts := make([]int, 0, len(fields)) + from := 0 + for _, field := range fields { + pos := strings.Index(line[from:], field) + if pos < 0 { + t.Fatalf("field %q not found in %q", field, line) + } + pos += from + starts = append(starts, pos) + from = pos + len(field) + } + return starts +} diff --git a/benchkit/cmd/sweep/runstate.go b/benchkit/cmd/sweep/runstate.go new file mode 100644 index 00000000..52c73734 --- /dev/null +++ b/benchkit/cmd/sweep/runstate.go @@ -0,0 +1,124 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + lastRunStateName = ".sweep-last.json" + collectScriptName = "collect.sh" + latestRunSentinel = "__latest__" +) + +// lastRunState is the laptop-side pointer needed to recover the most recently +// launched driver run without remembering either its driver or remote path. +type lastRunState struct { + Driver string `json:"driver"` + RemoteWorkDir string `json:"remote_work_dir"` + RemoteNamespace string `json:"remote_namespace"` + Label string `json:"label"` + LaunchedAt time.Time `json:"launched_at"` + LocalRunDir string `json:"local_run_dir"` + SSHConfig string `json:"ssh_config,omitempty"` + TransferMode string `json:"transfer_mode"` + Collection string `json:"collection"` +} + +func lastRunStatePath(rootDir string) string { + return filepath.Join(rootDir, lastRunStateName) +} + +func writeLastRunState(rootDir string, state lastRunState) error { + if err := os.MkdirAll(rootDir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + tmp, err := os.CreateTemp(rootDir, lastRunStateName+".tmp-") + if err != nil { + return err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o644); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, lastRunStatePath(rootDir)) +} + +func readLastRunState(rootDir string) (lastRunState, error) { + data, err := os.ReadFile(lastRunStatePath(rootDir)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return lastRunState{}, fmt.Errorf("no previous driver run recorded in %s", lastRunStatePath(rootDir)) + } + return lastRunState{}, err + } + var state lastRunState + if err := json.Unmarshal(data, &state); err != nil { + return lastRunState{}, fmt.Errorf("read %s: %w", lastRunStatePath(rootDir), err) + } + if state.Driver == "" || state.RemoteWorkDir == "" || state.LocalRunDir == "" { + return lastRunState{}, fmt.Errorf("%s is missing driver, remote_work_dir, or local_run_dir", lastRunStatePath(rootDir)) + } + return state, nil +} + +func updateLastRunCollection(rootDir, remoteWorkDir, collection string) { + state, err := readLastRunState(rootDir) + if err != nil || state.RemoteWorkDir != remoteWorkDir { + return + } + state.Collection = collection + if err := writeLastRunState(rootDir, state); err != nil { + // Collection already succeeded; failure to refresh a convenience pointer + // must not turn that success into a failed collection. + fmt.Fprintf(os.Stderr, "warning: update %s: %v\n", lastRunStatePath(rootDir), err) + } +} + +func writeCollectScript(outDir string, state lastRunState) (string, error) { + path := filepath.Join(outDir, collectScriptName) + var args []string + args = append(args, "-driver", state.Driver, "-collect="+state.RemoteWorkDir, "-outdir", filepath.Dir(state.LocalRunDir)) + if state.SSHConfig != "" { + args = append(args, "-config", state.SSHConfig) + } + if state.TransferMode != "" { + args = append(args, "-transfer", state.TransferMode) + } + var b strings.Builder + b.WriteString("#!/bin/sh\nset -eu\n\n") + b.WriteString("# Safe collection waits for the remote run to finish.\n") + b.WriteString("exec ./cmd/sweep/sweep") + for _, arg := range args { + b.WriteByte(' ') + b.WriteString(shellQuote(arg)) + } + b.WriteByte('\n') + if err := os.WriteFile(path, []byte(b.String()), 0o755); err != nil { + return "", err + } + return path, nil +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} diff --git a/benchkit/cmd/sweep/runstate_test.go b/benchkit/cmd/sweep/runstate_test.go new file mode 100644 index 00000000..19d024f4 --- /dev/null +++ b/benchkit/cmd/sweep/runstate_test.go @@ -0,0 +1,85 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestNormalizeOptionalPathArgs(t *testing.T) { + got := normalizeOptionalPathArgs([]string{"sweep", "-collect", "/local/a run", "-outdir", "out"}) + want := []string{"sweep", "-collect=/local/a run", "-outdir", "out"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + got = normalizeOptionalPathArgs([]string{"sweep", "-collect-now", "-driver", "bb1"}) + want = []string{"sweep", "-collect-now", "-driver", "bb1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + + // The double-dash forms, which Go's flag package treats identically to + // the single-dash forms, must be normalized the same way; otherwise + // "--collect " parses as a bare boolean followed by a stray + // positional argument, silently collecting the latest run instead of the + // requested one (see main.go's flag.NArg() check for the other half of + // this fix). + got = normalizeOptionalPathArgs([]string{"sweep", "--collect", "/local/a run", "-outdir", "out"}) + want = []string{"sweep", "--collect=/local/a run", "-outdir", "out"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + got = normalizeOptionalPathArgs([]string{"sweep", "--collect-now", "-driver", "bb1"}) + want = []string{"sweep", "--collect-now", "-driver", "bb1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} + +func TestLastRunStateRoundTripAndCollectScript(t *testing.T) { + root := t.TempDir() + runDir := filepath.Join(root, "run one") + if err := os.MkdirAll(runDir, 0o755); err != nil { + t.Fatal(err) + } + want := lastRunState{ + Driver: "bb1", RemoteWorkDir: "/local/sweep-me/run one", + RemoteNamespace: "/local/sweep-me", Label: "run one", + LaunchedAt: time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC), + LocalRunDir: runDir, SSHConfig: "/tmp/ssh config", TransferMode: "rsync", + Collection: "pending", + } + if err := writeLastRunState(root, want); err != nil { + t.Fatal(err) + } + got, err := readLastRunState(root) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } + path, err := writeCollectScript(runDir, want) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) == "" || !containsAll(string(data), "'-collect=/local/sweep-me/run one'", "'-driver'", "'bb1'") { + t.Fatalf("unexpected collect script:\n%s", data) + } +} + +func containsAll(s string, values ...string) bool { + for _, value := range values { + if !strings.Contains(s, value) { + return false + } + } + return true +} diff --git a/benchkit/cmd/sweep/status.go b/benchkit/cmd/sweep/status.go new file mode 100644 index 00000000..bd81be5c --- /dev/null +++ b/benchkit/cmd/sweep/status.go @@ -0,0 +1,75 @@ +package main + +import ( + "maps" + "slices" + "strconv" +) + +// runStatusRecord tallies run outcomes for one node count. completed counts the +// runs that produced usable measurements (succeeded plus degraded); a degraded +// run kept intact per-node data even though one node fell below the health +// threshold, so it still contributes to performance aggregates. +type runStatusRecord struct { + nodes int + total int + succeeded int + degraded int + failed int + completed int +} + +// runStatusRows tallies the run manifests under outdir by node count. It +// captures the completion accounting that performance figures alone omit: how +// many repetitions of each cluster size finished cleanly, degraded, or failed. +func runStatusRows(outdir string) ([]runStatusRecord, error) { + manifests, err := loadRunManifests(outdir) + if err != nil { + return nil, err + } + byNodes := make(map[int]*runStatusRecord) + for _, rm := range manifests { + n := rm.manifest.Nodes + rec := byNodes[n] + if rec == nil { + rec = &runStatusRecord{nodes: n} + byNodes[n] = rec + } + rec.total++ + switch rm.manifest.Status { + case runStatusSucceeded: + rec.succeeded++ + rec.completed++ + case runStatusDegraded: + rec.degraded++ + rec.completed++ + case runStatusFailed: + rec.failed++ + } + } + out := make([]runStatusRecord, 0, len(byNodes)) + for _, n := range slices.Sorted(maps.Keys(byNodes)) { + out = append(out, *byNodes[n]) + } + return out, nil +} + +// writeRunStatusCSV writes the per-node-count run outcome tallies. +func writeRunStatusCSV(path string, rows []runStatusRecord) error { + return writeCSV(path, + []string{"nodes", "total", "succeeded", "degraded", "failed", "completed"}, + rows, func(r runStatusRecord) []string { + return []string{ + strconv.Itoa(r.nodes), strconv.Itoa(r.total), strconv.Itoa(r.succeeded), + strconv.Itoa(r.degraded), strconv.Itoa(r.failed), strconv.Itoa(r.completed), + } + }) +} + +// anyDegradedOrFailed reports whether any run did not succeed cleanly, so the +// report includes the run-status figure only when outcomes are worth showing. +func anyDegradedOrFailed(rows []runStatusRecord) bool { + return slices.ContainsFunc(rows, func(r runStatusRecord) bool { + return r.degraded > 0 || r.failed > 0 + }) +} diff --git a/benchkit/cmd/sweep/summary.go b/benchkit/cmd/sweep/summary.go new file mode 100644 index 00000000..310ee5ce --- /dev/null +++ b/benchkit/cmd/sweep/summary.go @@ -0,0 +1,130 @@ +package main + +import ( + "fmt" + "log" + "maps" + "os" + "path/filepath" + "slices" + "text/tabwriter" + "time" + + "github.com/relab/gorums/benchkit" +) + +// benchSummary accumulates one benchmark's results across all nodes of a run. +type benchSummary struct { + throughput float64 // summed across nodes (cluster aggregate) + latency benchkit.LatencyDist // merged across nodes, from raw samples or histograms + nodes int // number of nodes that contributed + cvSum float64 // sum of per-node throughput CV values (averaged at print time) + cvCount int // number of nodes that reported a valid CV +} + +// printRunSummary loads the per-node result files for a completed run and prints +// an aggregated table: throughput summed across nodes, latency percentiles +// recomputed from the merged samples (or from the merged histograms for HDR +// runs). Missing or unreadable files are skipped with a warning so a partial +// run still reports what it collected. +func printRunSummary(outdir, base string, nodes []nodeAssignment, trim time.Duration) { + byBench := make(map[string]*benchSummary) + for _, node := range nodes { + path := filepath.Join(outdir, resultFilename(base, node, resultExt)) + data, err := os.ReadFile(path) + if err != nil { + log.Printf(" warning: summary: %v", err) + continue + } + if err := parseBinaryResultFile(data, byBench, trim); err != nil { + log.Printf(" warning: summary: parse %s: %v", filepath.Base(path), err) + } + } + if len(byBench) == 0 { + return + } + + tw := tabwriter.NewWriter(log.Writer(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, " BENCHMARK\tTHROUGHPUT\tCV\tMEAN\tSTDDEV\tp50\tp95\tp99\tNODES\tSAMPLES") + for _, name := range slices.Sorted(maps.Keys(byBench)) { + s := byBench[name] + cvStr := "-" + if s.cvCount > 0 { + cvStr = fmt.Sprintf("%.1f%%", 100*s.cvSum/float64(s.cvCount)) + } + // Latency columns come from the merged raw samples when any node + // contributed them, and from the merged histograms (HDR runs; whole-run, + // since the histogram has no time dimension) otherwise. + meanStr, stddevStr, p50, p95, p99 := "n/a", "n/a", "n/a", "n/a", "n/a" + var samples uint64 + if !s.latency.Empty() { + mean, stddev := s.latency.MeanAndStdDev() + qs := s.latency.Quantiles(0.50, 0.95, 0.99) + meanStr, stddevStr = fmtDur(int64(mean)), fmtDur(int64(stddev)) + p50, p95, p99 = fmtDur(int64(qs[0])), fmtDur(int64(qs[1])), fmtDur(int64(qs[2])) + samples = s.latency.Count() + } + fmt.Fprintf(tw, " %s\t%.0f ops/s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\n", + name, s.throughput, cvStr, meanStr, stddevStr, p50, p95, p99, + s.nodes, samples) + } + tw.Flush() +} + +// parseBinaryResultFile decodes a binary result file and merges its contents +// into byBench. +func parseBinaryResultFile(data []byte, byBench map[string]*benchSummary, trim time.Duration) error { + res, err := benchkit.DecodeReport(data) + if err != nil { + return err + } + mergeResults(res, byBench, trim) + return nil +} + +// mergeResults merges the summary fields (name, throughput, latencies or +// histogram) of every Result in res into byBench, trimming intervals and +// samples recorded before trim (see [benchkit.Summarize]). Other schema fields +// are ignored, and additive schema changes are tolerated by protobuf's wire +// compatibility. +// +// Throughput is summed only from client-measured results when any exist in +// this report, so a PBFT primary-client run contributes primary ops/s rather +// than primary+Σbackup execute rates. When every result is server-measured, +// thruputs are still summed (symmetric multi-node clients). +func mergeResults(res *benchkit.Report, byBench map[string]*benchSummary, trim time.Duration) { + results := res.GetResults() + hasClient := false + for _, r := range results { + if r.GetConfig().GetMeasurementMode() == benchkit.MeasurementMode_CLIENT_MEASURED { + hasClient = true + break + } + } + for _, r := range results { + name := r.GetConfig().GetName() + if name == "" { + continue + } + s := byBench[name] + if s == nil { + s = &benchSummary{} + byBench[name] = s + } + node := benchkit.Summarize(r, trim) + clientMeasured := r.GetConfig().GetMeasurementMode() == benchkit.MeasurementMode_CLIENT_MEASURED + if !hasClient || clientMeasured { + s.throughput += node.Throughput + } + s.nodes++ + s.latency.Merge(node.Dist()) + if node.CVValid { + s.cvSum += node.CV + s.cvCount++ + } + } +} + +func fmtDur(ns int64) string { + return time.Duration(ns).String() +} diff --git a/benchkit/cmd/sweep/summary_test.go b/benchkit/cmd/sweep/summary_test.go new file mode 100644 index 00000000..52d15c78 --- /dev/null +++ b/benchkit/cmd/sweep/summary_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "maps" + "math" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/relab/gorums/benchkit" +) + +// buildBinaryResultFile returns the bytes of a result file as +// [benchkit.WriteReport] writes them, so the test exercises the same decode +// path sweep uses in production rather than a hand-built framing. +func buildBinaryResultFile(t *testing.T, name string, throughput float64, latencies []int64) []byte { + t.Helper() + results := []*benchkit.Result{benchkit.Result_builder{ + Config: benchkit.RunConfig_builder{Name: name}.Build(), + Throughput: throughput, + Latencies: latencies, + }.Build()} + path := filepath.Join(t.TempDir(), "results"+resultExt) + if err := benchkit.WriteLabeledReport(results, "test", path); err != nil { + t.Fatalf("WriteLabeledReport: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} + +// TestBinaryResultsDecode verifies that parseBinaryResultFile extracts name, +// throughput, and latencies from a binary result file into the run summary. +func TestBinaryResultsDecode(t *testing.T) { + wantName := "QuorumCall" + wantThroughput := 12345.6 + wantLatencies := []int64{100, 200, 300} + + file := buildBinaryResultFile(t, wantName, wantThroughput, wantLatencies) + + byBench := make(map[string]*benchSummary) + if err := parseBinaryResultFile(file, byBench, 0); err != nil { + t.Fatalf("parseBinaryResultFile: %v", err) + } + + s, ok := byBench[wantName] + if !ok { + t.Fatalf("no entry for %q; got keys: %v", wantName, slices.Collect(maps.Keys(byBench))) + } + if s.throughput != wantThroughput { + t.Errorf("throughput = %v, want %v", s.throughput, wantThroughput) + } + if got := s.latency.Count(); got != uint64(len(wantLatencies)) { + t.Errorf("latency samples = %d, want %d", got, len(wantLatencies)) + } + // Mean over 100, 200, 300. + if mean, _ := s.latency.MeanAndStdDev(); mean != 200 { + t.Errorf("latency mean = %v, want 200", mean) + } + if s.nodes != 1 { + t.Errorf("nodes = %d, want 1", s.nodes) + } +} + +// TestBinaryResultsRejectsNonBinary verifies parseBinaryResultFile rejects a +// file that does not carry the binary magic header. +func TestBinaryResultsRejectsNonBinary(t *testing.T) { + byBench := make(map[string]*benchSummary) + if err := parseBinaryResultFile([]byte(`{"label":"x","results":[]}`), byBench, 0); err == nil { + t.Error("parseBinaryResultFile(non-binary) = nil error, want error") + } +} + +// TestMergeResultsMeasurementMode verifies that throughput is summed from +// client-measured results when any exist in the report, so a PBFT-style +// primary-client run reports the primary's client ops/s rather than +// primary+Σbackup execute rates, and that a report with no client-measured +// result sums every node (symmetric multi-node clients). +func TestMergeResultsMeasurementMode(t *testing.T) { + result := func(mode benchkit.MeasurementMode, throughput float64) *benchkit.Result { + return benchkit.Result_builder{ + Config: benchkit.RunConfig_builder{ + Name: "Q", MeasurementMode: mode, + }.Build(), + Throughput: throughput, + }.Build() + } + tests := []struct { + name string + results []*benchkit.Result + want float64 + }{ + { + name: "MixedSumsClientOnly", + results: []*benchkit.Result{ + result(benchkit.MeasurementMode_CLIENT_MEASURED, 5000), + result(benchkit.MeasurementMode_SERVER_MEASURED, 50000), + result(benchkit.MeasurementMode_SERVER_MEASURED, 50000), + }, + want: 5000, + }, + { + name: "ServerOnlySumsAll", + results: []*benchkit.Result{ + result(benchkit.MeasurementMode_SERVER_MEASURED, 5000), + result(benchkit.MeasurementMode_SERVER_MEASURED, 6000), + }, + want: 11000, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + byBench := make(map[string]*benchSummary) + mergeResults(benchkit.Report_builder{Results: tt.results}.Build(), byBench, 0) + s := byBench["Q"] + if s == nil { + t.Fatal("no summary for Q") + } + if s.throughput != tt.want { + t.Errorf("throughput = %v, want %v", s.throughput, tt.want) + } + if s.nodes != len(tt.results) { + t.Errorf("nodes = %d, want %d", s.nodes, len(tt.results)) + } + }) + } +} + +// TestMergeResultsHDRHistograms verifies that the histograms of nodes without +// raw samples (HDR mode) merge by value across the reports of a run, and that +// the merged distribution yields the cluster-wide mean, stddev, and +// percentiles. +func TestMergeResultsHDRHistograms(t *testing.T) { + node := func(values []int64, counts []uint64) *benchkit.Report { + return benchkit.Report_builder{ + Results: []*benchkit.Result{ + benchkit.Result_builder{ + Config: benchkit.RunConfig_builder{ + Name: "Q", + StatsMode: benchkit.StatsMode_HDR, + }.Build(), + Histogram: benchkit.LatencyHistogram_builder{Value: values, Count: counts}.Build(), + }.Build(), + }, + }.Build() + } + byBench := make(map[string]*benchSummary) + mergeResults(node([]int64{100, 200}, []uint64{5, 10}), byBench, 0) + mergeResults(node([]int64{200, 400}, []uint64{10, 15}), byBench, 0) + + s := byBench["Q"] + if s == nil { + t.Fatal("no summary for Q") + } + // Merged weights: 100×5, 200×20, 400×15. + if got := s.latency.Count(); got != 40 { + t.Errorf("merged samples = %d, want 40", got) + } + // Weighted mean: (100·5 + 200·20 + 400·15) / 40 = 262.5. + mean, stddev := s.latency.MeanAndStdDev() + if mean != 262.5 { + t.Errorf("mean = %v, want 262.5", mean) + } + // Population variance: (5·162.5² + 20·62.5² + 15·137.5²) / 40. + wantSD := math.Sqrt((5*162.5*162.5 + 20*62.5*62.5 + 15*137.5*137.5) / 40) + if math.Abs(stddev-wantSD) > 1e-9 { + t.Errorf("stddev = %v, want %v", stddev, wantSD) + } + // p50: the 20th of 40 samples is 200; p95: the 38th is 400. + qs := s.latency.Quantiles(0.50, 0.95) + if qs[0] != 200 || qs[1] != 400 { + t.Errorf("quantiles = %v, want [200 400]", qs) + } +} diff --git a/benchkit/cmd/sweep/sweep.go b/benchkit/cmd/sweep/sweep.go new file mode 100644 index 00000000..ce6264e3 --- /dev/null +++ b/benchkit/cmd/sweep/sweep.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "iter" + + "github.com/relab/gorums/benchkit" +) + +// runSpec holds the dimensions and repetition for one benchmark run. +type runSpec struct { + benchkit.Dimensions + Rep int `json:"rep"` // repetition number, 1-based +} + +// sweepConfig holds the parameter ranges for a sweep. +// The [params] method produces the Cartesian product of all combinations. +// An empty sendBuffers or recvBuffers contributes one zero value, which selects +// the benchmark binary's default. +type sweepConfig struct { + numNodes []int + workers []int + payloads []int + rates []int + sendBuffers []int + recvBuffers []int + benchmarks []string + streamModes []string + reps int +} + +// bufferValues returns sizes, or a single default-selecting zero when sizes is +// empty, so an unswept buffer axis contributes exactly one combination. +func bufferValues(sizes []int) []int { + if len(sizes) == 0 { + return []int{0} + } + return sizes +} + +// params returns an iterator over all swept benchmark parameter combinations. +func (sc sweepConfig) params() iter.Seq[runSpec] { + return func(yield func(runSpec) bool) { + reps := max(sc.reps, 1) + streamModes := sc.streamModes + if len(streamModes) == 0 { + streamModes = []string{"dual"} + } + for rep := 1; rep <= reps; rep++ { + for _, n := range sc.numNodes { + for _, workers := range sc.workers { + for _, payload := range sc.payloads { + for _, rate := range sc.rates { + for _, sendBuffer := range bufferValues(sc.sendBuffers) { + for _, recvBuffer := range bufferValues(sc.recvBuffers) { + for _, benchmark := range sc.benchmarks { + for _, streamMode := range streamModes { + if !yield(runSpec{ + Dimensions: benchkit.Dimensions{ + Benchmark: benchmark, + Nodes: n, + Workers: workers, + Payload: payload, + Rate: rate, + SendBuffer: sendBuffer, + RecvBuffer: recvBuffer, + StreamMode: streamMode, + }, + Rep: rep, + }) { + return + } + } + } + } + } + } + } + } + } + } + } +} + +// runBase returns the base filename prefix for a run's output files, +// matching the naming convention the report generator expects. +// Format: