Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ built for the [wardnet](https://github.com/wardnet) daemon's benchmarks.
1. Your CI runs the benchmarks (today: Rust [criterion](https://github.com/bheisler/criterion.rs)).
2. `cubit compare` ingests the output, diffs it against the baseline recorded on
the `cubit-state` branch, and posts a Markdown table (baseline vs current, Δ%)
plus a link to a trend dashboard.
plus a link to a trend dashboard. Larger suites are folded into collapsible
sections by benchmark group (criterion's `group/function/value` prefix), so
the comment and dashboard stay readable as the suite grows — sections holding
a regression open by default.
3. On the default branch, `cubit record` updates the baseline — keyed by commit
SHA — for the next PR to compare against.

Expand Down
22 changes: 11 additions & 11 deletions internal/dashboard/assets/index.html

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
// the numbers.
package model

import "sort"
import (
"sort"
"strings"
)

// SchemaVersion is bumped whenever the on-disk [Run] shape changes
// incompatibly, so the ingest/read layers can refuse or migrate old records.
Expand Down Expand Up @@ -82,3 +85,15 @@ func Compare(current, baseline Run, thresholdPct float64) []Delta {
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}

// GroupKey returns a benchmark's group: the leading id segment before the first
// "/". criterion nests ids as "group/function/value", so this is the
// benchmark_group the bench was declared in — the natural axis for folding a
// large suite into sections. A top-level bench_function has no "/" and no
// group, so its key is "" (rendered as an "ungrouped" bucket downstream).
func GroupKey(id string) string {
if i := strings.IndexByte(id, '/'); i >= 0 {
return id[:i]
}
return ""
}
129 changes: 121 additions & 8 deletions internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package report

import (
"fmt"
"sort"
"strings"

"wardnet/cubit/internal/model"
Expand Down Expand Up @@ -41,12 +42,18 @@ func Markdown(deltas []model.Delta, cur model.Run, thresholdPct float64, dashboa
b.WriteString("✅ No benchmark exceeded the regression threshold.\n\n")
}

b.WriteString("| Benchmark | Baseline | Current | Δ | |\n")
b.WriteString("|---|--:|--:|--:|:-:|\n")
for _, d := range deltas {
b.WriteString(row(d))
// A flat table is fine for a handful of benches, but a suite of dozens spread
// across several criterion groups swamps the comment. When there is more than
// one group, fold each into a collapsible section — clean groups collapsed,
// groups with a regression expanded — so a reviewer sees what moved without
// scrolling a wall of rows.
if groups := groupDeltas(deltas); len(groups) > 1 {
for _, g := range groups {
writeGroup(&b, g)
}
} else {
writeTable(&b, deltas, "")
}
b.WriteString("\n")

if newCount > 0 {
fmt.Fprintf(&b, "_%d benchmark(s) are new (no baseline to compare)._\n\n", newCount)
Expand All @@ -58,12 +65,106 @@ func Markdown(deltas []model.Delta, cur model.Run, thresholdPct float64, dashboa
return b.String()
}

func row(d model.Delta) string {
// group is a set of deltas sharing a benchmark group (their leading id segment).
type group struct {
name string // the shared group; "" for ungrouped, top-level benches
deltas []model.Delta
}

// groupDeltas buckets deltas by [model.GroupKey], preserving each group's
// internal order (Compare already sorted by id) and returning groups sorted by
// name with the ungrouped bucket last, so the sections read alphabetically and
// the miscellany falls to the bottom.
func groupDeltas(deltas []model.Delta) []group {
index := map[string]*group{}
var order []*group
for _, d := range deltas {
name := model.GroupKey(d.ID)
g, ok := index[name]
if !ok {
g = &group{name: name}
index[name] = g
order = append(order, g)
}
g.deltas = append(g.deltas, d)
}
sort.Slice(order, func(i, j int) bool {
a, z := order[i].name, order[j].name
if (a == "") != (z == "") {
return a != "" // ungrouped ("") sorts last
}
return a < z
})
out := make([]group, len(order))
for i, g := range order {
out[i] = *g
}
return out
}

// writeGroup renders one collapsible section: a summary line carrying the
// group's name, size and status, and — inside — its table. The section is
// pre-expanded when the group holds a regression, so problems are visible
// without a click while healthy groups stay folded.
func writeGroup(b *strings.Builder, g group) {
name := g.name
if name == "" {
name = "ungrouped"
}
open := ""
if countRegressed(g.deltas) > 0 {
open = " open"
}
fmt.Fprintf(b, "<details%s><summary><strong>%s</strong> · %d benchmark%s · %s</summary>\n\n",
open, name, len(g.deltas), plural(len(g.deltas)), groupStatus(g.deltas))
writeTable(b, g.deltas, g.name)
b.WriteString("</details>\n\n")
}

// writeTable renders the benchmark table for deltas. groupName, when non-empty,
// is stripped from each row's id so a "dns" section lists "parse/a" rather than
// repeating "dns/parse/a" on every row.
func writeTable(b *strings.Builder, deltas []model.Delta, groupName string) {
b.WriteString("| Benchmark | Baseline | Current | Δ | |\n")
b.WriteString("|---|--:|--:|--:|:-:|\n")
for _, d := range deltas {
b.WriteString(row(d, trimGroup(d.ID, groupName)))
}
b.WriteString("\n")
}

// groupStatus is the one-glance verdict shown in a group's summary line: a
// regression count wins, otherwise a speedup, otherwise a clean check.
func groupStatus(deltas []model.Delta) string {
if r := countRegressed(deltas); r > 0 {
return fmt.Sprintf("⚠️ %d slower", r)
}
if countFaster(deltas) > 0 {
return "🚀 faster"
}
return "✅"
}

func trimGroup(id, groupName string) string {
if groupName == "" {
return id
}
return strings.TrimPrefix(id, groupName+"/")
}

func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}

func row(d model.Delta, label string) string {
if !d.HasBase {
return fmt.Sprintf("| `%s` | — | %s | _new_ | 🆕 |\n", d.ID, formatNs(d.Current))
return fmt.Sprintf("| `%s` | — | %s | _new_ | 🆕 |\n", label, formatNs(d.Current))
}
return fmt.Sprintf("| `%s` | %s | %s | %s | %s |\n",
d.ID, formatNs(d.Baseline), formatNs(d.Current), formatPct(d.PctChange), marker(d))
label, formatNs(d.Baseline), formatNs(d.Current), formatPct(d.PctChange), marker(d))
}

func marker(d model.Delta) string {
Expand Down Expand Up @@ -97,6 +198,18 @@ func countNew(deltas []model.Delta) int {
return n
}

// countFaster counts benchmarks that got meaningfully faster — the same
// threshold marker() uses for the 🚀 flag, so the summary and rows agree.
func countFaster(deltas []model.Delta) int {
n := 0
for _, d := range deltas {
if d.HasBase && d.PctChange < -5 {
n++
}
}
return n
}

// formatNs renders a nanosecond value with an adaptive unit, so a table mixing
// sub-microsecond cache lookups and millisecond pipeline runs stays readable.
func formatNs(ns float64) string {
Expand Down
94 changes: 94 additions & 0 deletions internal/report/report_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package report

import (
"strings"
"testing"

"wardnet/cubit/internal/model"
)

func delta(id string, base, cur float64, threshold float64) model.Delta {
d := model.Delta{ID: id, Unit: "ns", Current: cur}
if base > 0 {
d.HasBase = true
d.Baseline = base
d.PctChange = (cur - base) / base * 100
d.Regressed = d.PctChange > threshold
}
return d
}

func TestMarkdownGroupsIntoSections(t *testing.T) {
deltas := []model.Delta{
delta("dns/parse/a", 100, 102, 15),
delta("dns/parse/aaaa", 100, 130, 15), // regression
delta("cache/get/hit", 40, 41, 15),
delta("cache/put/large", 800, 808, 15),
}
md := Markdown(deltas, model.Run{Commit: "abc"}, 15, "")

if strings.Count(md, "<details") != 2 {
t.Fatalf("expected 2 group sections, got %d\n%s", strings.Count(md, "<details"), md)
}
// The group holding the regression is pre-expanded; the clean one stays folded.
if !strings.Contains(md, "<details open><summary><strong>dns</strong>") {
t.Errorf("dns group with a regression should be open:\n%s", md)
}
if !strings.Contains(md, "<details><summary><strong>cache</strong>") {
t.Errorf("clean cache group should be collapsed:\n%s", md)
}
// Rows inside a section drop the redundant group prefix.
if !strings.Contains(md, "| `parse/a` |") {
t.Errorf("row id should be stripped of its group prefix:\n%s", md)
}
if strings.Contains(md, "| `dns/parse/a` |") {
t.Errorf("row id should not repeat the group prefix:\n%s", md)
}
}

func TestMarkdownSingleGroupStaysFlat(t *testing.T) {
deltas := []model.Delta{
delta("dns/parse/a", 100, 101, 15),
delta("dns/parse/aaaa", 100, 102, 15),
}
md := Markdown(deltas, model.Run{}, 15, "")
if strings.Contains(md, "<details") {
t.Errorf("a single group should render a flat table, not sections:\n%s", md)
}
// A flat table keeps the full id, since there is no section header for context.
if !strings.Contains(md, "| `dns/parse/a` |") {
t.Errorf("flat table should keep the full id:\n%s", md)
}
}

func TestGroupStatus(t *testing.T) {
regressed := []model.Delta{delta("g/x", 100, 130, 15)}
if got := groupStatus(regressed); !strings.HasPrefix(got, "⚠️") {
t.Errorf("regressed group status = %q, want a ⚠️ slower verdict", got)
}
faster := []model.Delta{delta("g/x", 100, 90, 15)}
if got := groupStatus(faster); got != "🚀 faster" {
t.Errorf("faster group status = %q, want 🚀 faster", got)
}
flat := []model.Delta{delta("g/x", 100, 101, 15)}
if got := groupStatus(flat); got != "✅" {
t.Errorf("unchanged group status = %q, want ✅", got)
}
}

func TestGroupDeltasOrdersUngroupedLast(t *testing.T) {
deltas := []model.Delta{
delta("startup", 100, 101, 15), // ungrouped
delta("router/x", 100, 101, 15),
delta("cache/y", 100, 101, 15),
}
groups := groupDeltas(deltas)
got := make([]string, len(groups))
for i, g := range groups {
got[i] = g.name
}
want := []string{"cache", "router", ""}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Errorf("group order = %v, want %v (ungrouped last)", got, want)
}
}
68 changes: 60 additions & 8 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
import { useEffect, useState } from "react";
import type { Dashboard } from "./types";
import { loadData } from "./data";
import type { BenchSeries, Dashboard } from "./types";
import { groupKey, loadData } from "./data";
import { LineChart } from "./LineChart";
import logo from "./assets/cubit-logo.png";

interface Group {
name: string;
series: BenchSeries[];
}

// groupSeries buckets benchmarks by their leading id segment, preserving each
// group's incoming order (already id-sorted by the Go side) and returning groups
// sorted by name with the ungrouped bucket last — mirroring the PR comment's
// sectioning so the two views read the same.
function groupSeries(benchmarks: BenchSeries[]): Group[] {
const byName = new Map<string, BenchSeries[]>();
for (const b of benchmarks) {
const key = groupKey(b.id);
const bucket = byName.get(key);
if (bucket) bucket.push(b);
else byName.set(key, [b]);
}
return [...byName.entries()]
.map(([name, series]) => ({ name, series }))
.sort((a, b) => {
if ((a.name === "") !== (b.name === "")) return a.name === "" ? 1 : -1;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
}

// stripGroup drops the redundant "group/" prefix from a benchmark's id for its
// caption inside that group's section (the full id still labels the chart).
function stripGroup(id: string, group: string): string {
return group && id.startsWith(group + "/") ? id.slice(group.length + 1) : id;
}

export function App() {
const [data, setData] = useState<Dashboard | null | undefined>(undefined);

Expand All @@ -26,22 +57,43 @@ export function App() {
);
}

const groups = groupSeries(data.benchmarks);

return (
<main className="wrap">
<header>
<h1>
<img className="logo" src={logo} alt="" /> cubit — performance
</h1>
<p className="muted">
{data.benchmarks.length} benchmarks · branch <code>{data.branch}</code> ·
{data.benchmarks.length} benchmarks · {groups.length} group
{groups.length === 1 ? "" : "s"} · branch <code>{data.branch}</code> ·
generated {new Date(data.generatedAt).toISOString().replace("T", " ").slice(0, 16)}
</p>
</header>
<div className="grid">
{data.benchmarks.map((b) => (
<LineChart key={b.id} series={b} />
))}
</div>
{groups.length <= 1 ? (
<div className="grid">
{data.benchmarks.map((b) => (
<LineChart key={b.id} series={b} />
))}
</div>
) : (
groups.map((g) => (
<details className="group" key={g.name || "ungrouped"} open>
<summary>
<span className="group-name">{g.name || "ungrouped"}</span>
<span className="group-count">
{g.series.length} benchmark{g.series.length === 1 ? "" : "s"}
</span>
</summary>
<div className="grid">
{g.series.map((b) => (
<LineChart key={b.id} series={b} label={stripGroup(b.id, g.name)} />
))}
</div>
</details>
))
)}
</main>
);
}
Loading
Loading