feat: load ramp to find where each app slows down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev c2b92636ae96238696891138891aa86560ca6a9f parent dee8c7f
8 files changed, +139 −37
  • docs/superpowers/specs/2026-10-23-todomvc-bakeoff-design.md +1 −1
  • harness/cmd/bakeoff/main.go +14 −7
  • harness/harness_test.go +27 −0
  • harness/report/report.go +41 −24
  • harness/report/report.html.tmpl +2 −2
  • harness/report/report_test.go +4 −3
  • harness/sweep.go +49 −0
  • scripts/measure-live.sh +1 −0
diff --git a/docs/superpowers/specs/2026-10-23-todomvc-bakeoff-design.md b/docs/superpowers/specs/2026-10-23-todomvc-bakeoff-design.md
index e9f0ab9..08bfdca 100644
--- a/docs/superpowers/specs/2026-10-23-todomvc-bakeoff-design.md
+++ b/docs/superpowers/specs/2026-10-23-todomvc-bakeoff-design.md
@@ -212,7 +212,7 @@ All of them are collected by scripts in this repo and can be re-run.
| Build | Three build times for the arm64 binary that ships: **cold** (the app and every third-party library compiled from already-downloaded sources; compiler and standard library as shipped), **warm** (libraries already compiled, app rebuilt from clean), and **one-line change** (rebuild after a trivial edit in the request-handling code). Plus binary size. Paul asked for cold and warm, 2026-10-23. Measured on docs-t1 where the app cross-compiles there; otherwise on the machine that built it, with the machine named beside the number and kept out of the ranking. |
| Response time | Time to the first byte for `GET /` with an empty list and with 50 items, from docs-t1, median of repeated runs. |
| Wake time | Time to the first byte on the first request after the instance has gone to sleep. |
-| Under load | Fixed scenarios at fixed concurrency levels (read-heavy: `GET /` on a 50-item list; write-heavy: add and toggle). Reports requests per second, p50/p95/p99 latency and error rate. |
+| Under load | A ramp for reads (`GET /` on a 50-item list) and for writes (toggling an item): 1, 2, 4 … 1,024 concurrent connections, 10 s per step. Reports peak requests per second, latency at every step, and **where it slows down**: the most connections at which doubling the load still raised throughput by 10% or more. A ramp stops early after two flat doublings or more than 1% errors. Paul asked for this, 2026-10-23, replacing fixed loads at 1, 16 and 64. |
| Memory | Peak and idle memory from the platform, if it exposes them. If it doesn't, we file a request against the platform rather than measure inside the app. |
| Page weight | Bytes of HTML and JS sent for `GET /`. |
diff --git a/harness/cmd/bakeoff/main.go b/harness/cmd/bakeoff/main.go
index 0b69614..4ebe6e7 100644
--- a/harness/cmd/bakeoff/main.go
+++ b/harness/cmd/bakeoff/main.go
@@ -30,9 +30,9 @@ func main() {
results := fs.String("results", "results", "results directory")
dir := fs.String("dir", "", "app directory (default apps/<slug>)")
runs := fs.Int("runs", 3, "build: runs per mode")
- dur := fs.Duration("duration", 30*time.Second, "load: length of each run")
- rest := fs.Duration("rest", 60*time.Second, "load: pause between runs")
- levels := fs.String("levels", "1,16,64", "load: concurrency levels")
+ dur := fs.Duration("duration", 10*time.Second, "load: length of each step")
+ rest := fs.Duration("rest", 10*time.Second, "load: pause between steps")
+ levels := fs.String("levels", "1,2,4,8,16,32,64,128,256,512,1024", "load: concurrency levels, doubling")
phase := fs.String("phase", "", "tokens: build|fix|security|review")
logs := fs.String("logs", "builders/logs", "tokens: log directory")
since := fs.String("since", "", "tokens: log whose cumulative totals this resumed phase starts from (default: the previous phase's)")
@@ -157,30 +157,37 @@ func wake(slug, url string) error {
return b.Update(slug, func(r *b.AppResult) { r.Wake = append(r.Wake, ms) })
}
+// load ramps each scenario through the levels, stopping early when the
+// app has stopped scaling or is failing (b.SweepShouldStop).
func load(slug, url, levels string, d, rest time.Duration) error {
s, err := seeded(url, 50)
if err != nil {
return err
}
var out []b.LoadResult
- first := true
for _, sc := range []string{"read", "toggle"} {
+ var sweep []b.LoadResult
for _, l := range strings.Split(levels, ",") {
c, err := strconv.Atoi(strings.TrimSpace(l))
if err != nil {
return err
}
- if !first {
+ if len(out)+len(sweep) > 0 {
time.Sleep(rest)
}
- first = false
r, err := b.RunLoad(s, sc, c, d)
if err != nil {
return err
}
fmt.Printf("%s %s c=%d: %.0f rps, p50 %.1fms, p99 %.1fms, errors %.2f%%\n", slug, sc, c, r.RPS, r.P50Ms, r.P99Ms, r.ErrorRate*100)
- out = append(out, r)
+ sweep = append(sweep, r)
+ if b.SweepShouldStop(sweep) {
+ break
+ }
}
+ k := b.Knee(sweep)
+ fmt.Printf("%s %s: peak %.0f rps; slows down after %d (found %v, tested to %d)\n", slug, sc, k.PeakRPS, k.Concurrency, k.Found, k.MaxTested)
+ out = append(out, sweep...)
}
return b.Update(slug, func(r *b.AppResult) { r.Load = out })
}
diff --git a/harness/harness_test.go b/harness/harness_test.go
index 9be9ca3..57304b8 100644
--- a/harness/harness_test.go
+++ b/harness/harness_test.go
@@ -274,3 +274,30 @@ func TestParseClaudeLogSumsTurnsAcrossResults(t *testing.T) {
t.Fatalf("got %+v (usage and cost are cumulative: take the last; turns and time are per result: sum)", tk)
}
}
+
+func lr(c int, rps, p99, errs float64) LoadResult {
+ return LoadResult{Scenario: "read", Concurrency: c, RPS: rps, P99Ms: p99, ErrorRate: errs}
+}
+
+func TestKneeIsFirstDoublingWithLittleGain(t *testing.T) {
+ sweep := []LoadResult{lr(1, 20, 60, 0), lr(2, 40, 60, 0), lr(4, 79, 61, 0), lr(8, 150, 70, 0), lr(16, 160, 130, 0), lr(32, 162, 260, 0)}
+ k := Knee(sweep)
+ if !k.Found || k.Concurrency != 8 || k.PeakRPS != 162 {
+ t.Fatalf("got %+v, want slowdown after 8 (16 added under 10%%), peak 162", k)
+ }
+ if k := Knee(sweep[:4]); k.Found || k.PeakRPS != 150 || k.MaxTested != 8 {
+ t.Fatalf("got %+v, want not found within 8", k)
+ }
+}
+
+func TestSweepStopsOnErrorsOrFlatThroughput(t *testing.T) {
+ if !SweepShouldStop([]LoadResult{lr(1, 20, 60, 0), lr(2, 40, 60, 0.02)}) {
+ t.Fatal("should stop on >1% errors")
+ }
+ if !SweepShouldStop([]LoadResult{lr(4, 100, 60, 0), lr(8, 105, 90, 0), lr(16, 108, 180, 0)}) {
+ t.Fatal("should stop after two flat doublings")
+ }
+ if SweepShouldStop([]LoadResult{lr(4, 100, 60, 0), lr(8, 105, 90, 0), lr(16, 190, 90, 0)}) {
+ t.Fatal("one flat step then growth: keep going")
+ }
+}
diff --git a/harness/report/report.go b/harness/report/report.go
index 7809619..cbfe986 100644
--- a/harness/report/report.go
+++ b/harness/report/report.go
@@ -16,20 +16,20 @@ import (
var pageSrc string
var page = template.Must(template.New("page").Funcs(template.FuncMap{
- "md": func(c Copy, key string) template.HTML { return markdown(c[key]) },
- "ms": ms,
- "mb": mb,
- "num": num,
- "usd": usd,
- "secs": secs,
- "live": live,
- "total": func(r b.AppResult) b.Tokens { return r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity) },
- "wake": func(r b.AppResult) float64 { return medianOf(r.Wake) },
- "read64": func(r b.AppResult) b.LoadResult { return load(r, "read", 64) },
- "loc": func(c b.CodeStats) int { return c.Server + c.Templates + c.JS },
- "add3": func(x b.Behaviour) int { return x.Passed + x.Failed + x.Skipped },
- "float": func(n int64) float64 { return float64(n) },
- "inc": func(i int) int { return i + 1 },
+ "md": func(c Copy, key string) template.HTML { return markdown(c[key]) },
+ "ms": ms,
+ "mb": mb,
+ "num": num,
+ "usd": usd,
+ "secs": secs,
+ "live": live,
+ "total": func(r b.AppResult) b.Tokens { return r.TokensBuild.Add(r.TokensFix).Add(r.TokensSecurity) },
+ "wake": func(r b.AppResult) float64 { return medianOf(r.Wake) },
+ "peak": func(r b.AppResult, sc string) float64 { return knee(r, sc).PeakRPS },
+ "loc": func(c b.CodeStats) int { return c.Server + c.Templates + c.JS },
+ "add3": func(x b.Behaviour) int { return x.Passed + x.Failed + x.Skipped },
+ "float": func(n int64) float64 { return float64(n) },
+ "inc": func(i int) int { return i + 1 },
}).Parse(pageSrc))
func live(r b.AppResult) bool { return strings.HasPrefix(r.Status, "LIVE") }
@@ -60,13 +60,30 @@ func medianOf(v []float64) float64 {
return c[len(c)/2]
}
-func load(r b.AppResult, scenario string, c int) b.LoadResult {
+func knee(r b.AppResult, scenario string) b.KneeResult {
+ var sweep []b.LoadResult
for _, l := range r.Load {
- if l.Scenario == scenario && l.Concurrency == c {
- return l
+ if l.Scenario == scenario {
+ sweep = append(sweep, l)
}
}
- return b.LoadResult{}
+ return b.Knee(sweep)
+}
+
+// kneeChart shows, per app, the concurrency it still scaled at; an app
+// that never stopped scaling shows that instead of a bar.
+func kneeChart(rs []b.AppResult, scenario, title, note string) Chart {
+ c := chart(rs, title, note, func(v float64) string { return num(v) + " connections" },
+ func(r b.AppResult) (float64, bool) {
+ k := knee(r, scenario)
+ return float64(k.Concurrency), k.Found
+ })
+ for i, r := range rs {
+ if k := knee(r, scenario); live(r) && !k.Found && k.MaxTested > 0 {
+ c.Rows[i].Missing = "still scaling at " + num(float64(k.MaxTested))
+ }
+ }
+ return c
}
// chart builds one chart over all results; get returns ok=false when the
@@ -112,12 +129,12 @@ func charts(rs []b.AppResult) []Chart {
func(r b.AppResult) (float64, bool) { return pos(r.TTFB50.P50) }),
chart(rs, "Wake from sleep", "Time to first byte on the first request after the instance went to sleep. Median of 3.", ms,
func(r b.AppResult) (float64, bool) { return pos(medianOf(r.Wake)) }),
- chart(rs, "Reads per second, 64 at once", "GET / on a 50-item list for 30 s with 64 concurrent connections.", num,
- func(r b.AppResult) (float64, bool) { return pos(load(r, "read", 64).RPS) }),
- chart(rs, "Read latency (p99), 64 at once", "The slowest 1% of those reads took at least this long.", ms,
- func(r b.AppResult) (float64, bool) { return pos(load(r, "read", 64).P99Ms) }),
- chart(rs, "Toggles per second, 64 at once", "POST toggling one item for 30 s with 64 concurrent connections.", num,
- func(r b.AppResult) (float64, bool) { return pos(load(r, "toggle", 64).RPS) }),
+ chart(rs, "Peak reads per second", "GET / on a 50-item list, ramping from 1 to 1,024 concurrent connections, 10 s per step.", num,
+ func(r b.AppResult) (float64, bool) { return pos(knee(r, "read").PeakRPS) }),
+ kneeChart(rs, "read", "Reads: where it slows down", "The most concurrent connections at which doubling the load still raised throughput by 10% or more."),
+ chart(rs, "Peak toggles per second", "POST toggling one item, ramping the same way.", num,
+ func(r b.AppResult) (float64, bool) { return pos(knee(r, "toggle").PeakRPS) }),
+ kneeChart(rs, "toggle", "Toggles: where it slows down", "The most concurrent connections at which doubling the load still raised throughput by 10% or more."),
chart(rs, "Binary size", "The linux-arm64 binary that ships.", mb,
func(r b.AppResult) (float64, bool) { return pos(float64(r.Build.Bytes)) }),
chart(rs, "Cold build", "Every library compiled from already-downloaded sources.", secs,
diff --git a/harness/report/report.html.tmpl b/harness/report/report.html.tmpl
index e96c5ac..69dfb16 100644
--- a/harness/report/report.html.tmpl
+++ b/harness/report/report.html.tmpl
@@ -56,13 +56,13 @@ dt { color: var(--muted); } dd { margin: 0; color: var(--ink-2); }
<h2>At a glance</h2>
<div class="card scroll">
<table>
-<thead><tr><th>Setup</th><th>Tests (JS on / off)</th><th>First byte</th><th>Wake</th><th>Reads/s @64</th><th>Binary</th><th>Cold / warm build</th><th>Lines</th><th>Deps</th><th>Cost</th><th>Readability</th></tr></thead>
+<thead><tr><th>Setup</th><th>Tests (JS on / off)</th><th>First byte</th><th>Wake</th><th>Peak reads/s</th><th>Binary</th><th>Cold / warm build</th><th>Lines</th><th>Deps</th><th>Cost</th><th>Readability</th></tr></thead>
<tbody>
{{- range .Results}}
{{- if live .}}
<tr><td>{{.Language}}</td>
<td>{{.BehaviourJSOn.Passed}}/{{add3 .BehaviourJSOn}} · {{.BehaviourJSOff.Passed}}/{{add3 .BehaviourJSOff}}</td>
-<td>{{ms .TTFBEmpty.P50}}</td><td>{{ms (wake .)}}</td><td>{{num (read64 .).RPS}}</td>
+<td>{{ms .TTFBEmpty.P50}}</td><td>{{ms (wake .)}}</td><td>{{num (peak . "read")}}</td>
<td>{{mb (float .Build.Bytes)}}</td><td>{{secs .Build.ColdSeconds}} / {{secs .Build.WarmSeconds}}</td>
<td>{{loc .Code}}</td><td>{{.Code.Deps}}</td><td>{{usd (total .).CostUSD}}</td><td>{{.Readability.Overall}}/10</td></tr>
{{- else}}
diff --git a/harness/report/report_test.go b/harness/report/report_test.go
index 81a0b58..f72bfb9 100644
--- a/harness/report/report_test.go
+++ b/harness/report/report_test.go
@@ -14,8 +14,9 @@ func fixture() []b.AppResult {
Stack: "net/http, html/template, modernc.org/sqlite",
BehaviourJSOn: b.Behaviour{Passed: 35}, BehaviourJSOff: b.Behaviour{Passed: 33, Skipped: 2},
TTFBEmpty: b.Stats{N: 30, P50: 41.2}, TTFB50: b.Stats{N: 30, P50: 45.9},
- Wake: []float64{120, 140, 130},
- Load: []b.LoadResult{{Scenario: "read", Concurrency: 64, RPS: 2100, P50Ms: 30, P95Ms: 40, P99Ms: 55}},
+ Wake: []float64{120, 140, 130},
+ Load: []b.LoadResult{{Scenario: "read", Concurrency: 1, RPS: 20}, {Scenario: "read", Concurrency: 2, RPS: 40},
+ {Scenario: "read", Concurrency: 4, RPS: 41}, {Scenario: "toggle", Concurrency: 1, RPS: 15}},
Weight: b.Weight{HTMLBytes: 9000, JSBytes: 2400},
Code: b.CodeStats{Server: 300, Templates: 80, JS: 120, Tests: 200, Deps: 1},
Build: b.Build{ColdSeconds: 40, WarmSeconds: 2, EditSeconds: 1.1, Bytes: 9_400_000, Host: "docs-t1"},
@@ -35,7 +36,7 @@ func TestRenderAllApps(t *testing.T) {
out := buf.String()
for _, want := range []string{
"Go", "Mojo", "single-file binaries only", "https://todo-go.bfj.oncarlos.com", "<svg",
- "<p>Nine apps.</p>", `<a href="https://example.com">link</a>`, "Clear &amp; tidy &lt;ok&gt;", "9.4 MB",
+ "<p>Nine apps.</p>", `<a href="https://example.com">link</a>`, "Clear &amp; tidy &lt;ok&gt;", "9.4 MB", "2 connections", "still scaling at 1",
} {
if !strings.Contains(out, want) {
t.Errorf("output lacks %q", want)
diff --git a/harness/sweep.go b/harness/sweep.go
new file mode 100644
index 0000000..1075202
--- /dev/null
+++ b/harness/sweep.go
@@ -0,0 +1,49 @@
+package bakeoff
+
+// A sweep doubles the concurrent connections (1, 2, 4, …) and runs one
+// short load step at each, to find where an app starts to slow down.
+
+// flatGain is the throughput gain below which doubling the load counts
+// as no real gain: extra connections only add waiting.
+const flatGain = 1.10
+
+// maxErrorRate stops a sweep: past it, the numbers describe failures.
+const maxErrorRate = 0.01
+
+// KneeResult says where a sweep stopped scaling.
+type KneeResult struct {
+ Found bool // false: still scaling at MaxTested
+ Concurrency int // the last level that still scaled
+ PeakRPS float64 // the most requests per second any level reached
+ P50Ms float64 // median latency at Concurrency
+ MaxTested int
+}
+
+// Knee finds the first doubling that raised throughput by less than 10%:
+// the app slows down past the level before it.
+func Knee(sweep []LoadResult) KneeResult {
+ var k KneeResult
+ for i, r := range sweep {
+ if r.RPS > k.PeakRPS {
+ k.PeakRPS = r.RPS
+ }
+ k.MaxTested = r.Concurrency
+ if !k.Found && i > 0 && r.RPS < sweep[i-1].RPS*flatGain {
+ k.Found, k.Concurrency, k.P50Ms = true, sweep[i-1].Concurrency, sweep[i-1].P50Ms
+ }
+ }
+ return k
+}
+
+// SweepShouldStop is true once the last step had too many errors, or the
+// last two doublings were both flat.
+func SweepShouldStop(sweep []LoadResult) bool {
+ n := len(sweep)
+ if n == 0 {
+ return false
+ }
+ if sweep[n-1].ErrorRate > maxErrorRate {
+ return true
+ }
+ return n >= 3 && sweep[n-1].RPS < sweep[n-2].RPS*flatGain && sweep[n-2].RPS < sweep[n-3].RPS*flatGain
+}
diff --git a/scripts/measure-live.sh b/scripts/measure-live.sh
index a684c04..a5e9336 100755
--- a/scripts/measure-live.sh
+++ b/scripts/measure-live.sh
@@ -13,4 +13,5 @@ scripts/test-app.sh "$url" "$out/suite.json" >/dev/null 2>&1 || true
bakeoff behaviour --slug "$slug" --file "$out/suite.json"
bakeoff ttfb --slug "$slug"
bakeoff weight --slug "$slug"
+ulimit -n 8192 2>/dev/null || true
bakeoff load --slug "$slug"